From 8c94c4c464c2a3f2d3d9e085361c659ace0facbb Mon Sep 17 00:00:00 2001 From: complexluise Date: Tue, 30 Jun 2026 18:18:12 -0500 Subject: [PATCH 1/2] feat(sources): SemanticScholarSource como segunda fuente (Source protocol) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adaptador de Semantic Scholar (Academic Graph API) que cumple el Protocol Source (ADR 0042): seed con query nativa (sin traducción WoS), fetch_works_by_ids (POST /paper/batch, lotes <=500), fetch_citing_batch y _with_works (citantes directos via /paper/{id}/citations, duck-typed para el Forager) y _paper_to_row con identidad DOI-first (ADR 0036, reusa _normalize_doi para no introducir drift). Divergencias S2 vs OpenAlex: auth por header x-api-key (no Bearer), citantes directos sin el truco cites:+cruce de refs, paperId como source_id, limites propios. seed sin API key ante 429 levanta NetworkError accionable que nombra la key gratuita de S2 (sella D2). load() declara NotImplementedError. 35 tests con MockTransport (sin red en CI; 1 @network opcional). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bib2graph/sources/__init__.py | 9 +- src/bib2graph/sources/semanticscholar.py | 740 +++++++++++++++++++++++ tests/unit/test_semanticscholar.py | 669 ++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 1417 insertions(+), 3 deletions(-) create mode 100644 src/bib2graph/sources/semanticscholar.py create mode 100644 tests/unit/test_semanticscholar.py diff --git a/src/bib2graph/sources/__init__.py b/src/bib2graph/sources/__init__.py index 0b43ded..c972a25 100644 --- a/src/bib2graph/sources/__init__.py +++ b/src/bib2graph/sources/__init__.py @@ -1,14 +1,17 @@ """sources — costuras de siembra del ``Corpus``. Re-exporta el Protocol ``Source``, el modelo ``SeedResult``, y las -implementaciones disponibles en v0.1: +implementaciones disponibles: - ``OpenAlexSource``: backbone de datos (ADR 0007). Siembra desde la API de OpenAlex con traducción PASSTHROUGH y reporte de límites. +- ``SemanticScholarSource``: 2º backbone (ADR 0042). Siembra/forrajea/ + materializa desde la Academic Graph API de Semantic Scholar; query nativa + sin traducción, citantes directos vía ``/paper/{id}/citations``. - ``BibtexSource``: fuente secundaria. Siembra desde un archivo ``.bib`` (requiere el extra ``[bibtex]``). -Ver ``docs/API.md`` §2, ADR 0007/0012/0017/0018. +Ver ``docs/API.md`` §2, ADR 0007/0012/0017/0018/0042. """ from __future__ import annotations @@ -16,10 +19,12 @@ from bib2graph.sources.base import SeedResult, Source from bib2graph.sources.bibtex import BibtexSource from bib2graph.sources.openalex import OpenAlexSource +from bib2graph.sources.semanticscholar import SemanticScholarSource __all__ = [ "BibtexSource", "OpenAlexSource", "SeedResult", + "SemanticScholarSource", "Source", ] diff --git a/src/bib2graph/sources/semanticscholar.py b/src/bib2graph/sources/semanticscholar.py new file mode 100644 index 0000000..180bd1f --- /dev/null +++ b/src/bib2graph/sources/semanticscholar.py @@ -0,0 +1,740 @@ +"""semanticscholar — ``SemanticScholarSource``: siembra desde la Academic +Graph API de Semantic Scholar (S2). + +2º backbone real (ADR 0042), válida empíricamente la tesis de intercambiabilidad +de motores de ADR 0036/0018: el núcleo no se toca, S2 es "una ``Source`` nueva". +Toda la I/O usa ``httpx`` (núcleo); no depende de un SDK de S2. + +Divergencias S2↔OpenAlex (ADR 0042, no copiar a ciegas de ``openalex.py``): +- **Auth**: header ``x-api-key`` (no ``Authorization: Bearer``), sin email/mailto. +- **Asimetría de credenciales por rol (D2)**: ``seed()`` (``/paper/search``) + prácticamente exige ``api_key`` (429 sostenido sin ella); el forrajeo/ + materialización (``/paper/batch``, ``/paper/{id}/citations``) funciona sin key + a bajo volumen. Esto dobla —no rompe— el principio de ADR 0012. +- **Sin traducción WoS→S2 (D3)**: ``seed(query)`` pasa la query nativa tal cual; + ``translation_report`` lo declara honesto (sin capa ``_translate`` análoga a + OpenAlex). +- **Citantes directos**: ``GET /paper/{id}/citations`` devuelve los papers + citantes ya resueltos (campo ``citingPaper``); no hace falta el truco de + OpenAlex de cruzar ``cites:W1|W2`` con ``references_id``. No hay OR-batch en + S2: se itera por ID, acotando con ``max_per_paper`` (presupuesto trivial: cada + ID ya tiene su propio tope, sin competencia entre semillas). +- **IDs**: ``paperId`` es un hash propio de S2 (no una URL); se consulta por DOI + con el prefijo ``DOI:10.…`` (``/paper/batch`` acepta IDs heterogéneos sin + normalización adicional). +- **Límites por endpoint**: ``/paper/batch`` ≤500 IDs por lote (no las + constantes ≤50/≤100 de OpenAlex). +- **``keywords_raw``**: S2 no expone keywords ricas en estos endpoints; queda + ``None`` con el grado de degradación documentado en ADR 0018-B (el scent de + co-word se enrarece, no rompe). ``research_areas`` sí se puebla desde + ``fieldsOfStudy``/``s2FieldsOfStudy``. + +Fuera de este módulo (ADR 0042, D4/D5, explícitamente diferido): +- **Escritura de ``external_ids``**: el ``paperId`` vive en ``source_id`` + (cruza con OpenAlex vía DOI → mismo ``id`` canónico, dedup cross-motor + gratis), pero la población de la tabla lateral ``external_ids(engine= + "semanticscholar")`` (D4) no está cableada en este PR — el ADR autoriza + explícitamente recortarla a un PR aparte. +- **Selector CLI ``--source``** (D5): este módulo no se enchufa a ningún + comando; es su propio PR. +- Nota de riesgo para quien wire el ``Forager`` con este Source en el futuro: + ``forager.py`` materializa filas forward-chaining llamando directamente a + ``bib2graph.sources.openalex._work_to_row`` sobre el ``works_map`` que + devuelve ``fetch_citing_batch_with_works`` (import hardcodeado, no + duck-typed). El ``works_map`` que produce este módulo trae JSON crudo de S2 + (shape de ``_paper_to_row``, no de ``_work_to_row``); wireear S2 al Forager + requiere generalizar ese punto — no es parte de este hito. + +Ver ``docs/decisiones/0042-semantic-scholar-segundo-motor.md``. +""" + +from __future__ import annotations + +import os +import time +from datetime import UTC, date, datetime +from typing import Any + +import httpx +import pyarrow as pa + +from bib2graph.constants import Col, CurationStatus +from bib2graph.corpus import Corpus, EquationRef, _rows_with_ids +from bib2graph.schemas import CORPUS_SCHEMA, ProvenanceEvent +from bib2graph.service.errors import NetworkError +from bib2graph.sources.openalex import _normalize_doi + +from .base import SeedResult + +# Constantes internas + +_BASE_URL = "https://api.semanticscholar.org/graph/v1" + +# Fields exigidos por el contrato de seed() (D1, ADR 0042): mínimo universal + +# enriquecimiento barato. NO incluye "references": el costo de traerlas en cada +# resultado de búsqueda no se justifica en la siembra (se traen en fetch_works_by_ids +# / fetch_citing_*, donde el universo de papers ya está acotado). +_SEARCH_FIELDS = ",".join( + [ + "title", + "year", + "authors", + "abstract", + "externalIds", + "fieldsOfStudy", + "s2FieldsOfStudy", + "referenceCount", + "citationCount", + ] +) + +# Fields para fetch_works_by_ids / fetch_citing_*: agrega referencias (con DOI) +# para poblar references_id/references_doi en el mapeo completo. +_FULL_FIELDS = _SEARCH_FIELDS + ",references,references.externalIds" + +# Retry/backoff: mismos parámetros que OpenAlexSource (ADR 0042 no decide +# valores distintos). El seed NO reintenta (ver _MSG_RATE_LIMIT_429 / D2): +# es una llamada única del usuario: si falla por 429, el remedio es la key, +# no un retry ciego. +_RETRY_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504}) +_RETRY_MAX_ATTEMPTS: int = 3 +_RETRY_BACKOFF_BASE: float = 1.0 # segundos; duplica por cada intento + +# Mensaje accionable para 429 agotado en seed() (D2, ADR 0042): el remedio de +# S2 es la API key gratuita, no el "polite pool" de OpenAlex (ADR 0012). +_MSG_RATE_LIMIT_429 = ( + "Semantic Scholar respondió 429 (Too Many Requests) en /paper/search: el " + "rol sembrador de S2 prácticamente exige una API key (ADR 0042, D2). " + "Remedio: obtené una API key gratuita de Semantic Scholar " + "(https://www.semanticscholar.org/product/api#api-key) y pasala via " + "SemanticScholarSource(api_key='tu-key') o la variable de entorno " + "S2_API_KEY. El forrajeo (fetch_works_by_ids/fetch_citing_batch) sí " + "funciona sin key a bajo volumen. Ver ADR 0042." +) + +# Reporte de traducción honesto (D3, ADR 0042): no hay capa WoS→S2; la query +# se ejecuta nativa. +_TRANSLATION_REPORT_LINE = ( + "S2: query interpretada con sintaxis nativa de Semantic Scholar; sin traducción WoS" +) + + +# Helpers de mapeo JSON → fila del Corpus + + +def _paper_to_row( + paper: dict[str, Any], + *, + equation_id: str, + fetched_at: str, + is_seed: bool = True, + action: str = "fetched", + chaining_hop: int | None = None, + source_tag: str = "semanticscholar", +) -> dict[str, Any]: + """Mapea un objeto JSON de Semantic Scholar a la fila del schema canónico. + + Acceso defensivo con ``.get()`` en cadena (AGENTS.md), espejo funcional de + ``openalex._work_to_row``. El ``id`` canónico se calcula aparte (D1', + ADR 0036) por ``_rows_with_ids``/``Corpus.add_paper``: si el paper trae + DOI, ancla al mismo ``id`` que un paper de OpenAlex con el mismo DOI + (dedup cross-motor gratis, ADR 0042). + + Args: + paper: Objeto JSON de un paper de Semantic Scholar (``/paper/search``, + ``/paper/batch`` o el ``citingPaper`` de ``/paper/{id}/citations``). + equation_id: ID de la ecuación que originó la búsqueda (para provenance). + fetched_at: Timestamp ISO de la consulta. + is_seed: Si el paper es semilla (``True``) o candidato (``False``). + action: Tipo de evento de provenance (``'fetched'``, ``'fetched_by_id'``). + chaining_hop: Profundidad del chaining (1 = primera expansión), o + ``None`` si no aplica (seed/fetch_works_by_ids). + source_tag: Etiqueta de fuente para el evento de provenance. + Default ``'semanticscholar'``. + + Returns: + Dict con todas las columnas del schema canónico. + """ + external_ids: dict[str, Any] = paper.get("externalIds") or {} + doi = _normalize_doi(external_ids.get("DOI")) + source_id = paper.get("paperId") + + authors_list: list[dict[str, Any]] = paper.get("authors") or [] + authors_raw: list[str] = [] + authors_id: list[str] = [] + for author in authors_list: + name = author.get("name") or "" + if name: + authors_raw.append(name) + author_id = author.get("authorId") + authors_id.append(str(author_id) if author_id else (name or "unknown")) + + # research_areas: fieldsOfStudy (lista de strings) o, si está ausente, + # s2FieldsOfStudy[].category (más granular, puede repetir categorías). + fields_of_study: list[str] | None = paper.get("fieldsOfStudy") + research_areas: list[str] | None + if fields_of_study: + research_areas = [f for f in fields_of_study if f] + else: + s2_fields: list[dict[str, Any]] = paper.get("s2FieldsOfStudy") or [] + research_areas = [ + str(category) for f in s2_fields if (category := f.get("category")) + ] + research_areas = research_areas or None + + # keywords_raw: S2 no expone keywords ricas en estos endpoints (a diferencia + # de OpenAlex.keywords). Se deja vacío; el scent de co-word degrada en + # corpus mixtos OpenAlex+S2, no rompe (ADR 0018-B). + keywords_raw = None + + references_list: list[dict[str, Any]] = paper.get("references") or [] + references_id: list[str] = [] + references_doi: list[str] = [] + for ref in references_list: + ref_id = ref.get("paperId") + if ref_id: + references_id.append(ref_id) + ref_doi = _normalize_doi((ref.get("externalIds") or {}).get("DOI")) + if ref_doi: + references_doi.append(ref_doi) + + provenance_event = ProvenanceEvent( + action=action, + equation_id=equation_id, + chaining_hop=chaining_hop, + source=source_tag, + fetched_at=fetched_at, + decided_by=None, + decided_at=None, + ) + + return { + # id se calcula en Corpus.add_paper / _rows_with_ids (D1', ADR 0036) + Col.SOURCE_ID: source_id, + Col.DOI: doi, + Col.TITLE: paper.get("title") or "", + Col.YEAR: paper.get("year"), + Col.ABSTRACT: paper.get("abstract"), + Col.SOURCE: None, + Col.LANGUAGE: None, + Col.PUBLISHER: None, + Col.RESEARCH_AREAS: research_areas, + Col.IS_SEED: is_seed, + Col.CURATION_STATUS: CurationStatus.CANDIDATE, + Col.PROVENANCE: ProvenanceEvent.dump_list([provenance_event]), + Col.AUTHORS_RAW: authors_raw or None, + Col.AUTHORS_ID: authors_id or None, + Col.AUTHORS_AFFILIATIONS: None, + Col.KEYWORDS_RAW: keywords_raw, + Col.KEYWORDS_ID: None, + Col.INSTITUTIONS_RAW: None, + Col.INSTITUTIONS_ID: None, + Col.REFERENCES_ID: references_id or None, + Col.REFERENCES_DOI: references_doi or None, + Col.CITED_BY_ID: [], + } + + +def _empty_corpus_table() -> pa.Table: + """Tabla Arrow vacía con el schema canónico (helper para corpus vacío).""" + return pa.table( + {col: [] for col in CORPUS_SCHEMA.names}, + schema=CORPUS_SCHEMA, + ) + + +class SemanticScholarSource: + """Siembra/forrajea/materializa un ``Corpus`` desde Semantic Scholar (S2). + + Ejemplo:: + + source = SemanticScholarSource(api_key="tu-key") + result = source.seed("unequal exchange ecological debt") + print(result.executed_query) # query nativa, sin traducción + print(result.translation_report) # honestidad de D3, ADR 0042 + + Credenciales (ADR 0042 D2): ``api_key`` se inyecta, nunca embebida. + Resolución: argumento > entorno ``S2_API_KEY`` > ausencia. A diferencia de + OpenAlex (ADR 0012, ambos roles sin key), en S2 el rol **sembrador** + (``seed()``) prácticamente exige la key (429 sostenido sin ella); el + forrajeo/materialización (``fetch_works_by_ids``/``fetch_citing_batch``) + funciona sin key a bajo volumen. + + El ``transport`` permite pasar un ``httpx.MockTransport`` en tests (sin + red en CI). + """ + + def __init__( + self, + *, + api_key: str | None = None, + transport: httpx.BaseTransport | None = None, + base_url: str = _BASE_URL, + max_results: int = 200, + ) -> None: + """Inicializa el source. + + Args: + api_key: API key de Semantic Scholar (recomendada para ``seed()``; + opcional para forrajeo/materialización). Sin email/mailto: S2 + no tiene polite pool (a diferencia de OpenAlex, ADR 0012). + transport: Transport de httpx (inyectar ``MockTransport`` en tests). + base_url: URL base de la API (default producción). + max_results: Tope de resultados por llamada a ``seed()``. + """ + # Resolución de api_key: argumento > entorno > ausencia (D2, ADR 0042) + self._api_key = api_key or os.environ.get("S2_API_KEY") + self._base_url = base_url.rstrip("/") + self._max_results = max_results + self._transport = transport + + # Construcción del cliente httpx + + def _client(self) -> httpx.Client: + """Construye el cliente httpx con las credenciales inyectadas. + + Auth via header ``x-api-key`` (no ``Authorization: Bearer`` como + OpenAlex). Sin parámetro de email/mailto: S2 no tiene polite pool. + + Returns: + ``httpx.Client`` configurado. + """ + headers: dict[str, str] = {} + if self._api_key: + headers["x-api-key"] = self._api_key + if self._transport is not None: + return httpx.Client( + transport=self._transport, + base_url=self._base_url, + headers=headers, + ) + return httpx.Client(base_url=self._base_url, headers=headers) + + # Paginación de búsqueda (sin retry: ver módulo docstring / D2) + + def _fetch_search(self, query: str) -> list[dict[str, Any]]: + """Recupera papers de ``/paper/search`` paginando con offset/limit. + + Sin retry/backoff: ``seed()`` es una llamada única del usuario; un 429 + agotado debe aflorar de inmediato como ``NetworkError`` accionable + (D2, ADR 0042), no reintentarse en silencio. + + Args: + query: Query nativa de S2 (sin traducción, D3). + + Returns: + Lista de objetos JSON de papers, acotada a ``self._max_results``. + """ + papers: list[dict[str, Any]] = [] + offset = 0 + page_size = 100 # límite de página de /paper/search + + with self._client() as client: + while len(papers) < self._max_results: + remaining = self._max_results - len(papers) + limit = min(page_size, remaining) + resp = client.get( + "/paper/search", + params={ + "query": query, + "fields": _SEARCH_FIELDS, + "offset": offset, + "limit": limit, + }, + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + page_papers: list[dict[str, Any]] = data.get("data") or [] + papers.extend(page_papers) + + if not page_papers: + break + offset += len(page_papers) + total = data.get("total") + if total is not None and offset >= total: + break + + return papers[: self._max_results] + + # API pública + + def seed(self, query: str) -> SeedResult: + """Siembra un ``Corpus`` desde una query nativa de Semantic Scholar. + + No hay traducción WoS→S2 (D3, ADR 0042): la query se pasa tal cual a + ``/paper/search``; ``translation_report`` lo declara honesto. + + Args: + query: Query nativa de S2 (sintaxis propia de relevance search). + + Returns: + ``SeedResult`` con el corpus, la query ejecutada (idéntica a + ``query``) y el reporte de traducción (honesto: sin traducción). + + Raises: + NetworkError: Si S2 responde 429 (sin key, D2): el mensaje nombra + el remedio (API key gratuita de S2). + """ + equation_id = f"eq-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S')}" + fetched_at = datetime.now(UTC).isoformat() + + try: + papers = self._fetch_search(query) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 429: + raise NetworkError(_MSG_RATE_LIMIT_429) from exc + raise + + translation_report = [_TRANSLATION_REPORT_LINE] + + rows = [ + _paper_to_row(paper, equation_id=equation_id, fetched_at=fetched_at) + for paper in papers + ] + rows_with_ids = _rows_with_ids(rows) + if rows_with_ids: + table = pa.Table.from_pylist(rows_with_ids, schema=CORPUS_SCHEMA) + else: + table = _empty_corpus_table() + corpus = Corpus.from_arrow(table) + + updated_manifest = corpus.manifest.model_copy( + update={ + "equations": [ + EquationRef( + equation_id=equation_id, + query=query, + translation_report=translation_report, + ) + ], + } + ) + result_corpus = corpus.with_manifest(updated_manifest) + + return SeedResult( + corpus=result_corpus, + executed_query=query, + translation_report=translation_report, + ) + + def load(self, path: str) -> Corpus: + """No implementado: carga desde archivo S2 no soportada todavía. + + Decisión del PO: declarado explícitamente (fallar fuerte, no cablear + un stub silencioso), no diferido en silencio. Usá ``seed()`` para + sembrar por query o ``fetch_works_by_ids()`` para materializar IDs + conocidos. + + Args: + path: Ruta al archivo (sin uso; siempre lanza). + + Raises: + NotImplementedError: Siempre. + """ + raise NotImplementedError( + "SemanticScholarSource: carga desde archivo S2 no soportada " + "todavía; usá seed() para sembrar por query o " + "fetch_works_by_ids()/fetch_citing_batch() para materializar " + "candidatos. Ver ADR 0042." + ) + + def _fetch_batch_with_retry(self, ids: list[str]) -> list[dict[str, Any] | None]: + """Recupera un lote de papers de ``POST /paper/batch`` con retry/backoff. + + S2 devuelve un array alineado posicionalmente con ``ids``: ``None`` + en las posiciones de IDs no encontrados (en vez de simplemente + omitirlos como hace OpenAlex con el filtro OR). + + Args: + ids: Hasta 500 IDs de S2 (``paperId`` o ``DOI:10.…``, sin + normalización: S2 acepta IDs heterogéneos directamente). + + Returns: + Lista alineada con ``ids``: objeto JSON del paper o ``None``. + + Raises: + httpx.HTTPStatusError: Si se agotan los reintentos. + """ + last_exc: Exception | None = None + for attempt in range(_RETRY_MAX_ATTEMPTS): + try: + with self._client() as client: + resp = client.post( + "/paper/batch", + params={"fields": _FULL_FIELDS}, + json={"ids": ids}, + ) + resp.raise_for_status() + data: list[dict[str, Any] | None] = resp.json() + return data + except httpx.HTTPStatusError as exc: + if exc.response.status_code in _RETRY_STATUS_CODES: + last_exc = exc + wait = _RETRY_BACKOFF_BASE * (2**attempt) + time.sleep(wait) + else: + raise + assert last_exc is not None + raise last_exc + + def fetch_works_by_ids(self, ids: list[str]) -> Corpus: + """Trae papers completos de S2 a partir de una lista de IDs. + + Batchea en lotes de hasta 500 IDs (``POST /paper/batch``, límite del + endpoint — no las constantes ≤50/≤100 de OpenAlex). Acepta IDs de S2 + (``paperId``) o por DOI con prefijo ``DOI:10.…``: S2 los resuelve sin + normalización adicional de nuestra parte. + + Espejo funcional de ``OpenAlexSource.fetch_works_by_ids``: filas con + ``is_seed=False``, ``curation_status=CANDIDATE``, + ``provenance[action="fetched_by_id"]``, ordenadas por ``id`` canónico + (determinismo, ADR 0017). + + Args: + ids: Lista de IDs de S2 (``paperId`` o ``DOI:10.…``). + + Returns: + ``Corpus`` con los papers encontrados. IDs inexistentes (``None`` + en la respuesta posicional de S2) se omiten sin error. + """ + if not ids: + return Corpus.from_arrow(_empty_corpus_table()) + + fetched_at = datetime.now(UTC).isoformat() + all_rows: list[dict[str, Any]] = [] + batch_size = 500 + + for start in range(0, len(ids), batch_size): + lote = ids[start : start + batch_size] + papers = self._fetch_batch_with_retry(lote) + for paper in papers: + if not paper: + continue # ID no encontrado (S2 devuelve null posicional) + row = _paper_to_row( + paper, + equation_id="fetched_by_id", + fetched_at=fetched_at, + is_seed=False, + action="fetched_by_id", + ) + all_rows.append(row) + + rows_with_ids = _rows_with_ids(all_rows) + rows_with_ids.sort(key=lambda r: str(r.get(Col.ID, ""))) + + if rows_with_ids: + table = pa.Table.from_pylist(rows_with_ids, schema=CORPUS_SCHEMA) + else: + table = _empty_corpus_table() + return Corpus.from_arrow(table) + + def _fetch_citations_page_with_retry( + self, + client: httpx.Client, + paper_id: str, + *, + offset: int, + limit: int, + ) -> dict[str, Any]: + """Recupera una página de ``GET /paper/{id}/citations`` con retry/backoff. + + Args: + client: Cliente httpx ya abierto. + paper_id: ID de S2 (``paperId``) del paper semilla. + offset: Offset de paginación. + limit: Tamaño de página. + + Returns: + Objeto JSON de la respuesta (``{"offset", "next", "data"}``). + + Raises: + httpx.HTTPStatusError: Si se agotan los reintentos. + """ + last_exc: Exception | None = None + for attempt in range(_RETRY_MAX_ATTEMPTS): + try: + resp = client.get( + f"/paper/{paper_id}/citations", + params={ + "fields": _FULL_FIELDS, + "offset": offset, + "limit": limit, + }, + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + return data + except httpx.HTTPStatusError as exc: + if exc.response.status_code in _RETRY_STATUS_CODES: + last_exc = exc + wait = _RETRY_BACKOFF_BASE * (2**attempt) + time.sleep(wait) + else: + raise + assert last_exc is not None + raise last_exc + + def _fetch_citing_one( + self, + paper_id: str, + *, + max_per_paper: int | None, + since: date | None, + ) -> tuple[list[str], dict[str, dict[str, Any]]]: + """Trae los citantes directos de un paper vía ``/paper/{id}/citations``. + + DIVERGENCIA CLAVE (ADR 0042): S2 da los citantes ya resueltos + (``citingPaper``); no hace falta el truco de OpenAlex de cruzar + ``cites:W1|W2`` con ``references_id``. No hay OR-batch por varios IDs + en S2: se pagina por ID individual; el presupuesto ``max_per_paper`` + es trivialmente anti-starvation porque cada ID ya tiene su propio tope + (sin competencia entre semillas, a diferencia del batch OR de OpenAlex). + + ``since`` se aproxima por año (S2 no expone un filtro de fecha nativo + en este endpoint, a diferencia de ``from_publication_date`` de + OpenAlex): se descartan citantes con ``year < since.year``. + + Args: + paper_id: ID de S2 (``paperId``) del paper semilla. + max_per_paper: Tope de citantes a recolectar. ``None`` = sin tope. + since: Filtra citantes con año de publicación >= ``since.year``. + ``None`` = sin filtro. + + Returns: + Tupla ``(citer_ids, works_map)``: ``citer_ids`` ordenados + alfabéticamente y acotados a ``max_per_paper``; ``works_map`` es + ``{citer_id: citingPaper_json}`` con el objeto JSON completo de + cada citante (campos ``_FULL_FIELDS``). + """ + seen: set[str] = set() + works_map: dict[str, dict[str, Any]] = {} + offset = 0 + page_size = 100 + + if max_per_paper is not None and max_per_paper <= 0: + return [], {} + + with self._client() as client: + while max_per_paper is None or len(seen) < max_per_paper: + limit = page_size + if max_per_paper is not None: + limit = min(page_size, max_per_paper - len(seen)) + data = self._fetch_citations_page_with_retry( + client, paper_id, offset=offset, limit=limit + ) + entries: list[dict[str, Any]] = data.get("data") or [] + if not entries: + break + + for entry in entries: + citing_paper: dict[str, Any] = entry.get("citingPaper") or {} + citer_id = citing_paper.get("paperId") + if not citer_id: + continue + if since is not None: + year = citing_paper.get("year") + if year is not None and year < since.year: + continue + seen.add(citer_id) + works_map[citer_id] = citing_paper + if max_per_paper is not None and len(seen) >= max_per_paper: + break + + offset += len(entries) + if data.get("next") is None or len(entries) < limit: + break + + citer_ids = sorted(seen) + if max_per_paper is not None: + citer_ids = citer_ids[:max_per_paper] + works_map = {cid: works_map[cid] for cid in citer_ids} + return citer_ids, works_map + + def _fetch_citing_pages( + self, + ids: list[str], + *, + max_per_paper: int | None, + since: date | None, + ) -> tuple[dict[str, list[str]], dict[str, dict[str, Any]]]: + """Núcleo compartido de ``fetch_citing_batch``/``_with_works``. + + Itera por ID (S2 no soporta OR-batch en ``/citations``), acumulando + la atribución y el mapa de works JSON. + + Args: + ids: IDs de S2 (``paperId``) de las semillas. + max_per_paper: Tope de citantes por semilla. ``None`` = sin tope. + since: Filtro de año mínimo (aproximación, ver ``_fetch_citing_one``). + + Returns: + Tupla ``(attribution, works_map)``: ``attribution`` es + ``{seed_id: [citer_id, ...]}`` con una entrada por cada ID de + ``ids`` (incluso si la lista de citantes es vacía); ``works_map`` + es ``{citer_id: citingPaper_json}`` de todos los citantes únicos. + """ + attribution: dict[str, list[str]] = {} + works_map: dict[str, dict[str, Any]] = {} + for paper_id in ids: + citer_ids, paper_works = self._fetch_citing_one( + paper_id, max_per_paper=max_per_paper, since=since + ) + attribution[paper_id] = citer_ids + works_map.update(paper_works) + return attribution, works_map + + def fetch_citing_batch( + self, + ids: list[str], + *, + max_per_paper: int | None = None, + since: date | None = None, + ) -> dict[str, list[str]]: + """Trae los citantes directos de varios papers (uno a uno, sin OR-batch). + + Consumido por el ``Forager`` vía ``hasattr`` (duck-typing, sin + depender de un tipo común; ver ``foraging/forager.py``). + + Args: + ids: Lista de IDs de S2 (``paperId``) de las semillas. + max_per_paper: Tope de citantes a recolectar por semilla. + ``None`` = sin tope. + since: Filtra citantes con año de publicación >= ``since.year``. + + Returns: + Dict ``{seed_id: [citer_id, ...]}``, orden alfabético determinista. + ``{}`` si ``ids`` está vacío. + """ + if not ids: + return {} + attribution, _ = self._fetch_citing_pages( + ids, max_per_paper=max_per_paper, since=since + ) + return attribution + + def fetch_citing_batch_with_works( + self, + ids: list[str], + *, + max_per_paper: int | None = None, + since: date | None = None, + ) -> tuple[dict[str, list[str]], dict[str, dict[str, Any]]]: + """Como ``fetch_citing_batch`` pero conserva los objetos JSON completos. + + Sin red extra: los ``citingPaper`` ya vienen en las páginas de + ``/paper/{id}/citations`` que se usan para la atribución. + + Args: + ids: Lista de IDs de S2 (``paperId``) de las semillas. + max_per_paper: Tope de citantes por semilla. ``None`` = sin tope. + since: Filtra citantes con año de publicación >= ``since.year``. + + Returns: + Tupla ``(attribution, works_map)``. ``works_map`` trae JSON crudo + de S2 (shape de ``_paper_to_row``, NO de ``openalex._work_to_row`` + — ver nota de riesgo en el docstring del módulo). ``({}, {})`` si + ``ids`` está vacío. + """ + if not ids: + return {}, {} + return self._fetch_citing_pages(ids, max_per_paper=max_per_paper, since=since) diff --git a/tests/unit/test_semanticscholar.py b/tests/unit/test_semanticscholar.py new file mode 100644 index 0000000..5df5479 --- /dev/null +++ b/tests/unit/test_semanticscholar.py @@ -0,0 +1,669 @@ +"""Tests unitarios de ``SemanticScholarSource`` (ADR 0042). + +Todos los tests de red usan ``httpx.MockTransport`` (sin red real en CI), +salvo el marcado ``@pytest.mark.network`` (excluido del gate por defecto). + +Casos cubiertos (instrucción del epic): +1. ``seed`` con key (mock 200): mapeo S2→PaperRow, executed_query, + translation_report honesto, provenance.source="semanticscholar". +2. ``seed`` sin key (mock 429 sostenido): NetworkError accionable (D2), + sin loop infinito. +3. ``_paper_to_row``: dedup cross-motor vía DOI (mismo id que OpenAlex); + sin DOI → id cae a source_id; campos faltantes defensivos. +4. ``fetch_works_by_ids``: paginación >500 IDs; consulta por DOI:10.…. +5. ``fetch_citing_batch``/``_with_works``: atribución, max_per_paper, [] + para input vacío. +6. Paridad de contrato: isinstance(SemanticScholarSource(...), Source). +7. Un test @network opcional. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import httpx +import pytest + +from bib2graph.constants import Col, CurationStatus +from bib2graph.corpus import _rows_with_ids +from bib2graph.schemas import ProvenanceEvent +from bib2graph.service.errors import NetworkError +from bib2graph.sources.base import Source +from bib2graph.sources.openalex import _work_to_row +from bib2graph.sources.semanticscholar import SemanticScholarSource, _paper_to_row + +# --------------------------------------------------------------------------- +# Fixtures de datos S2 (JSON crudo, shape de la Academic Graph API) +# --------------------------------------------------------------------------- + +PAPER_CON_DOI: dict[str, Any] = { + "paperId": "649def34f8be52c8b66281af98ae884c09aef38", + "title": "Construction of the Literature Graph in Semantic Scholar", + "abstract": "We describe a deployed scalable system for organizing...", + "year": 2018, + "externalIds": {"DOI": "10.18653/V1/N18-3011", "MAG": "12345"}, + "authors": [ + {"authorId": "1741101", "name": "Waleed Ammar"}, + {"authorId": "1741102", "name": "Dirk Groeneveld"}, + ], + "fieldsOfStudy": ["Computer Science"], + "s2FieldsOfStudy": [{"category": "Computer Science", "source": "external"}], + "referenceCount": 2, + "citationCount": 10, +} + +PAPER_SIN_DOI: dict[str, Any] = { + "paperId": "abc123sindoinone", + "title": "A paper without DOI", + "abstract": None, + "year": 2020, + "externalIds": {}, + "authors": [], + "fieldsOfStudy": None, + "s2FieldsOfStudy": [], + "referenceCount": 0, + "citationCount": 0, +} + +PAPER_CAMPOS_MINIMOS: dict[str, Any] = { + "paperId": "minimo000", + "title": "Title only", +} + + +# --------------------------------------------------------------------------- +# Helpers de mock HTTP +# --------------------------------------------------------------------------- + + +def _make_search_handler(papers: list[dict[str, Any]]) -> httpx.MockTransport: + """MockTransport para /paper/search: una página con todos, luego vacía.""" + calls: list[int] = [0] + + def handler(request: httpx.Request) -> httpx.Response: + calls[0] += 1 + if calls[0] == 1: + body = {"total": len(papers), "offset": 0, "data": papers} + else: + body = {"total": len(papers), "offset": len(papers), "data": []} + return httpx.Response(200, json=body) + + return httpx.MockTransport(handler) + + +def _make_429_handler() -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, text="Too Many Requests") + + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- +# 1. seed con MockTransport (mapeo, executed_query, translation_report) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_seed_con_mock_transport_mapea_corpus() -> None: + """``seed()`` con MockTransport puebla el corpus con los papers fixture.""" + papers = [PAPER_CON_DOI, PAPER_SIN_DOI] + transport = _make_search_handler(papers) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("unequal exchange ecological debt") + + assert len(result.corpus) == len(papers) + + +@pytest.mark.unit +def test_seed_executed_query_es_la_query_nativa() -> None: + """``executed_query`` == query (sin traducción, D3 ADR 0042).""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + query = "complex systems thinking" + result = source.seed(query) + + assert result.executed_query == query + + +@pytest.mark.unit +def test_seed_translation_report_honesto() -> None: + """``translation_report`` declara explícitamente que no hubo traducción.""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + + assert len(result.translation_report) == 1 + report_line = result.translation_report[0] + assert "S2" in report_line + assert "sin traducción" in report_line or "sin traducción WoS" in report_line + + +@pytest.mark.unit +def test_seed_provenance_source_semanticscholar() -> None: + """Todas las filas sembradas tienen provenance.source == 'semanticscholar'.""" + transport = _make_search_handler([PAPER_CON_DOI, PAPER_SIN_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + table = result.corpus.to_arrow() + provenance_col = table.column(Col.PROVENANCE).to_pylist() + + for prov_json in provenance_col: + events = ProvenanceEvent.parse_list(prov_json) + assert len(events) >= 1 + assert events[0].source == "semanticscholar" + + +@pytest.mark.unit +def test_seed_is_seed_true_y_curation_candidate() -> None: + """``seed()`` marca is_seed=True y curation_status=candidate.""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + table = result.corpus.to_arrow() + + assert all(table.column(Col.IS_SEED).to_pylist()) + assert all( + s == CurationStatus.CANDIDATE + for s in table.column(Col.CURATION_STATUS).to_pylist() + ) + + +@pytest.mark.unit +def test_seed_authors_raw_mapeados() -> None: + """``authors[].name`` → ``authors_raw``.""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + table = result.corpus.to_arrow() + authors_raw = table.column(Col.AUTHORS_RAW).to_pylist()[0] + + assert authors_raw is not None + assert "Waleed Ammar" in authors_raw + assert "Dirk Groeneveld" in authors_raw + + +@pytest.mark.unit +def test_seed_research_areas_desde_fields_of_study() -> None: + """``fieldsOfStudy`` → ``research_areas``.""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + table = result.corpus.to_arrow() + research_areas = table.column(Col.RESEARCH_AREAS).to_pylist()[0] + + assert research_areas is not None + assert "Computer Science" in research_areas + + +@pytest.mark.unit +def test_seed_keywords_raw_vacio_declarado() -> None: + """``keywords_raw`` queda vacío (S2 no expone keywords ricas, ADR 0018-B).""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + result = source.seed("ecological exchange") + table = result.corpus.to_arrow() + keywords_raw = table.column(Col.KEYWORDS_RAW).to_pylist() + + assert all(k is None for k in keywords_raw) + + +@pytest.mark.unit +def test_seed_manifest_equations_query() -> None: + """``manifest.equations[0].query == executed_query``.""" + transport = _make_search_handler([PAPER_CON_DOI]) + source = SemanticScholarSource(api_key="test-key", transport=transport) + + query = "complex systems" + result = source.seed(query) + + assert len(result.corpus.manifest.equations) == 1 + assert result.corpus.manifest.equations[0].query == result.executed_query + + +# --------------------------------------------------------------------------- +# 2. seed sin key (429 sostenido) → NetworkError accionable +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_seed_429_levanta_network_error_accionable() -> None: + """Un 429 sostenido en /paper/search lanza NetworkError, no HTTPStatusError. + + Sella D2 (ADR 0042): el mensaje nombra la API key como remedio (no el + "polite pool" de OpenAlex). No debe loopear infinitamente: seed() no + reintenta (single-shot), por lo que un único 429 ya dispara el error. + """ + transport = _make_429_handler() + source = SemanticScholarSource(transport=transport) # sin api_key + + with pytest.raises(NetworkError) as exc_info: + source.seed("ecological exchange") + + msg = str(exc_info.value) + assert "429" in msg + assert "api key" in msg.lower() or "api_key" in msg.lower() + assert "S2_API_KEY" in msg + assert "ADR 0042" in msg + + +@pytest.mark.unit +def test_seed_429_no_es_httpstatuserror_pelado() -> None: + """El error levantado es NetworkError, no httpx.HTTPStatusError directo.""" + transport = _make_429_handler() + source = SemanticScholarSource(transport=transport) + + try: + source.seed("ecological exchange") + pytest.fail("Se esperaba una excepción") + except NetworkError: + pass + except httpx.HTTPStatusError: + pytest.fail("seed() no debe propagar httpx.HTTPStatusError pelado") + + +# --------------------------------------------------------------------------- +# 3. _paper_to_row: dedup cross-motor vía DOI, fallback sin DOI, defensivo +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_paper_to_row_con_doi_ancla_id_cross_motor() -> None: + """Un paper S2 con DOI obtiene el mismo id canónico que un work OpenAlex + con el mismo DOI (dedup cross-motor gratis, ADR 0036/0042).""" + fetched_at = "2026-06-30T00:00:00+00:00" + + s2_row = _paper_to_row(PAPER_CON_DOI, equation_id="eq-1", fetched_at=fetched_at) + openalex_work = { + "id": "https://openalex.org/W123", + "doi": "https://doi.org/10.18653/v1/N18-3011", + "title": "Construction of the Literature Graph (OpenAlex version)", + "publication_year": 2018, + } + oa_row = _work_to_row(openalex_work, equation_id="eq-2", fetched_at=fetched_at) + + [s2_with_id] = _rows_with_ids([s2_row]) + [oa_with_id] = _rows_with_ids([oa_row]) + + assert s2_with_id[Col.ID] == oa_with_id[Col.ID] + assert str(s2_with_id[Col.ID]).startswith("doi:") + + +@pytest.mark.unit +def test_paper_to_row_sin_doi_cae_a_source_id() -> None: + """Sin DOI, el id canónico cae al source_id (paperId).""" + fetched_at = "2026-06-30T00:00:00+00:00" + row = _paper_to_row(PAPER_SIN_DOI, equation_id="eq-1", fetched_at=fetched_at) + + assert row[Col.DOI] is None + assert row[Col.SOURCE_ID] == PAPER_SIN_DOI["paperId"] + + [with_id] = _rows_with_ids([row]) + assert str(with_id[Col.ID]).startswith("src:") + + +@pytest.mark.unit +def test_paper_to_row_campos_faltantes_sin_keyerror() -> None: + """Paper con solo title/paperId → sin KeyError, campos faltantes en None.""" + fetched_at = "2026-06-30T00:00:00+00:00" + + row = _paper_to_row(PAPER_CAMPOS_MINIMOS, equation_id="eq-1", fetched_at=fetched_at) + + assert row[Col.TITLE] == "Title only" + assert row[Col.SOURCE_ID] == "minimo000" + assert row[Col.DOI] is None + assert row[Col.YEAR] is None + assert row[Col.ABSTRACT] is None + assert row[Col.AUTHORS_RAW] is None + assert row[Col.RESEARCH_AREAS] is None + assert row[Col.REFERENCES_ID] is None + assert row[Col.REFERENCES_DOI] is None + assert row[Col.CITED_BY_ID] == [] + + +@pytest.mark.unit +def test_paper_to_row_provenance_source_tag() -> None: + """El evento de provenance usa source_tag (default 'semanticscholar').""" + fetched_at = "2026-06-30T00:00:00+00:00" + row = _paper_to_row(PAPER_CON_DOI, equation_id="eq-1", fetched_at=fetched_at) + + events = ProvenanceEvent.parse_list(row[Col.PROVENANCE]) + assert events[0].source == "semanticscholar" + + +@pytest.mark.unit +def test_paper_to_row_referencias_con_doi() -> None: + """``references[].{paperId, externalIds.DOI}`` → references_id/references_doi.""" + paper = dict(PAPER_CON_DOI) + paper["references"] = [ + {"paperId": "ref1", "externalIds": {"DOI": "10.1/ref1"}}, + {"paperId": "ref2", "externalIds": {}}, + {"paperId": None, "externalIds": {"DOI": "10.1/ref3"}}, + ] + fetched_at = "2026-06-30T00:00:00+00:00" + row = _paper_to_row(paper, equation_id="eq-1", fetched_at=fetched_at) + + assert row[Col.REFERENCES_ID] == ["ref1", "ref2"] + assert row[Col.REFERENCES_DOI] == ["10.1/ref1", "10.1/ref3"] + + +# --------------------------------------------------------------------------- +# 4. fetch_works_by_ids: paginación >500, consulta por DOI: +# --------------------------------------------------------------------------- + + +def _make_batch_handler( + response_for_call: list[list[dict[str, Any] | None]], +) -> tuple[httpx.MockTransport, list[dict[str, Any]]]: + """MockTransport para /paper/batch; registra cada request para inspección.""" + calls: list[int] = [0] + requests_seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + import json as _json + + body = _json.loads(request.content) + requests_seen.append(body) + idx = calls[0] + calls[0] += 1 + data = response_for_call[idx] if idx < len(response_for_call) else [] + return httpx.Response(200, json=data) + + return httpx.MockTransport(handler), requests_seen + + +@pytest.mark.unit +def test_fetch_works_by_ids_paginacion_mas_de_500() -> None: + """Más de 500 IDs → 2 lotes (POST /paper/batch paginado por ≤500).""" + ids = [f"paper{i}" for i in range(501)] + transport, requests_seen = _make_batch_handler([[], []]) + source = SemanticScholarSource(transport=transport) + + source.fetch_works_by_ids(ids) + + assert len(requests_seen) == 2 + assert len(requests_seen[0]["ids"]) == 500 + assert len(requests_seen[1]["ids"]) == 1 + + +@pytest.mark.unit +def test_fetch_works_by_ids_consulta_por_doi_prefijo() -> None: + """IDs con prefijo DOI: se pasan tal cual al body del batch (sin transformar).""" + transport, requests_seen = _make_batch_handler([[PAPER_CON_DOI]]) + source = SemanticScholarSource(transport=transport) + + corpus = source.fetch_works_by_ids(["DOI:10.18653/V1/N18-3011"]) + + assert requests_seen[0]["ids"] == ["DOI:10.18653/V1/N18-3011"] + assert len(corpus) == 1 + + +@pytest.mark.unit +def test_fetch_works_by_ids_is_seed_false_y_action_fetched_by_id() -> None: + """Papers traídos por ID: is_seed=False, provenance action='fetched_by_id'.""" + transport, _ = _make_batch_handler([[PAPER_CON_DOI]]) + source = SemanticScholarSource(transport=transport) + + corpus = source.fetch_works_by_ids(["649def34f8be52c8b66281af98ae884c09aef38"]) + table = corpus.to_arrow() + + assert all(v is False for v in table.column(Col.IS_SEED).to_pylist()) + for prov_json in table.column(Col.PROVENANCE).to_pylist(): + events = ProvenanceEvent.parse_list(prov_json) + assert events[0].action == "fetched_by_id" + + +@pytest.mark.unit +def test_fetch_works_by_ids_ids_inexistentes_se_omiten() -> None: + """S2 devuelve null posicional para IDs no encontrados → se omiten sin error.""" + transport, _ = _make_batch_handler([[PAPER_CON_DOI, None, None]]) + source = SemanticScholarSource(transport=transport) + + corpus = source.fetch_works_by_ids(["p1", "p2", "p3"]) + + assert len(corpus) == 1 + + +@pytest.mark.unit +def test_fetch_works_by_ids_lista_vacia_sin_request() -> None: + """Lista vacía → Corpus vacío sin request HTTP (sin transport, fallaría si llamara).""" + source = SemanticScholarSource() + + corpus = source.fetch_works_by_ids([]) + + assert len(corpus) == 0 + + +# --------------------------------------------------------------------------- +# 5. fetch_citing_batch / fetch_citing_batch_with_works +# --------------------------------------------------------------------------- + + +def _citing_paper(paper_id: str, year: int = 2021) -> dict[str, Any]: + return { + "paperId": paper_id, + "title": f"Citing paper {paper_id}", + "year": year, + "authors": [], + "externalIds": {}, + "abstract": None, + "fieldsOfStudy": None, + "s2FieldsOfStudy": [], + "referenceCount": 0, + "citationCount": 0, + } + + +def _make_citations_handler( + by_paper_id: dict[str, list[dict[str, Any]]], +) -> httpx.MockTransport: + """MockTransport para /paper/{id}/citations: una página por paper, sin 'next'.""" + + def handler(request: httpx.Request) -> httpx.Response: + # path: /graph/v1/paper/{id}/citations + parts = request.url.path.split("/") + paper_id = parts[parts.index("paper") + 1] + citers = by_paper_id.get(paper_id, []) + body = { + "offset": 0, + "data": [{"citingPaper": c} for c in citers], + } + return httpx.Response(200, json=body) + + return httpx.MockTransport(handler) + + +@pytest.mark.unit +def test_fetch_citing_batch_atribucion_basica() -> None: + """``fetch_citing_batch`` devuelve {seed_id: [citer_id, ...]}.""" + by_paper = { + "seed1": [_citing_paper("c1"), _citing_paper("c2")], + "seed2": [_citing_paper("c3")], + } + transport = _make_citations_handler(by_paper) + source = SemanticScholarSource(transport=transport) + + result = source.fetch_citing_batch(["seed1", "seed2"]) + + assert result["seed1"] == ["c1", "c2"] + assert result["seed2"] == ["c3"] + + +@pytest.mark.unit +def test_fetch_citing_batch_max_per_paper_respetado() -> None: + """``max_per_paper`` acota el número de citantes por semilla.""" + by_paper = { + "seed1": [_citing_paper(f"c{i}") for i in range(5)], + } + transport = _make_citations_handler(by_paper) + source = SemanticScholarSource(transport=transport) + + result = source.fetch_citing_batch(["seed1"], max_per_paper=2) + + assert len(result["seed1"]) == 2 + + +@pytest.mark.unit +def test_fetch_citing_batch_lista_vacia() -> None: + """``fetch_citing_batch([])`` → {} sin request HTTP.""" + source = SemanticScholarSource() + + assert source.fetch_citing_batch([]) == {} + + +@pytest.mark.unit +def test_fetch_citing_batch_with_works_devuelve_tupla() -> None: + """``fetch_citing_batch_with_works`` devuelve (attribution, works_map).""" + by_paper = {"seed1": [_citing_paper("c1")]} + transport = _make_citations_handler(by_paper) + source = SemanticScholarSource(transport=transport) + + attribution, works_map = source.fetch_citing_batch_with_works(["seed1"]) + + assert attribution == {"seed1": ["c1"]} + assert "c1" in works_map + assert works_map["c1"]["paperId"] == "c1" + + +@pytest.mark.unit +def test_fetch_citing_batch_with_works_lista_vacia() -> None: + """``fetch_citing_batch_with_works([])`` → ({}, {}) sin request HTTP.""" + source = SemanticScholarSource() + + attribution, works_map = source.fetch_citing_batch_with_works([]) + + assert attribution == {} + assert works_map == {} + + +@pytest.mark.unit +def test_fetch_citing_batch_since_filtra_por_anio() -> None: + """``since`` descarta citantes con año < since.year (aproximación, ver docstring).""" + by_paper = { + "seed1": [_citing_paper("old", year=2015), _citing_paper("new", year=2023)], + } + transport = _make_citations_handler(by_paper) + source = SemanticScholarSource(transport=transport) + + result = source.fetch_citing_batch(["seed1"], since=date(2020, 1, 1)) + + assert result["seed1"] == ["new"] + + +# --------------------------------------------------------------------------- +# 6. Paridad de contrato: Source Protocol +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_semantic_scholar_source_cumple_protocol_source() -> None: + """``SemanticScholarSource`` satisface el Protocol ``Source`` (runtime_checkable).""" + source = SemanticScholarSource() + assert isinstance(source, Source) + + +# --------------------------------------------------------------------------- +# Credenciales (sin red) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_api_key_desde_entorno(monkeypatch: pytest.MonkeyPatch) -> None: + """``api_key`` se resuelve desde ``S2_API_KEY`` si no hay argumento.""" + monkeypatch.setenv("S2_API_KEY", "env-key") + source = SemanticScholarSource() + assert source._api_key == "env-key" + + +@pytest.mark.unit +def test_api_key_argumento_gana_sobre_entorno(monkeypatch: pytest.MonkeyPatch) -> None: + """Argumento explícito gana sobre la variable de entorno.""" + monkeypatch.setenv("S2_API_KEY", "env-key") + source = SemanticScholarSource(api_key="explicit-key") + assert source._api_key == "explicit-key" + + +@pytest.mark.unit +def test_client_usa_header_x_api_key_no_bearer() -> None: + """El cliente usa el header ``x-api-key`` (no ``Authorization: Bearer``).""" + source = SemanticScholarSource(api_key="my-key") + client = source._client() + try: + assert client.headers.get("x-api-key") == "my-key" + assert "authorization" not in {k.lower() for k in client.headers} + finally: + client.close() + + +# --------------------------------------------------------------------------- +# load(): declarado, no implementado (decisión del PO) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_load_levanta_not_implemented_error() -> None: + """``load()`` levanta NotImplementedError con mensaje claro.""" + source = SemanticScholarSource() + + with pytest.raises(NotImplementedError, match="seed"): + source.load("algun/path.json") + + +# --------------------------------------------------------------------------- +# max_results +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_max_results_default_es_200() -> None: + source = SemanticScholarSource() + assert source._max_results == 200 + + +@pytest.mark.unit +def test_max_results_propagado() -> None: + source = SemanticScholarSource(max_results=30) + assert source._max_results == 30 + + +@pytest.mark.unit +def test_seed_max_results_corta_resultados() -> None: + """``seed()`` con ``max_results=1`` devuelve a lo sumo 1 paper.""" + papers = [PAPER_CON_DOI, PAPER_SIN_DOI] + transport = _make_search_handler(papers) + source = SemanticScholarSource(max_results=1, transport=transport) + + result = source.seed("ecological exchange") + + assert len(result.corpus) == 1 + + +# --------------------------------------------------------------------------- +# 7. Test de red real (excluido del gate por defecto) +# --------------------------------------------------------------------------- + + +@pytest.mark.network +def test_seed_red_real() -> None: + """Siembra real contra la API de S2 (sin key: bajo volumen, puede dar 429). + + Lección #30: validar contra la API real, no solo mocks. Si S2 devuelve 429 + por falta de key, el test documenta el comportamiento esperado (D2). + """ + source = SemanticScholarSource() + + try: + result = source.seed("complex systems thinking") + except NetworkError as exc: + assert "API key" in str(exc) or "api key" in str(exc).lower() + return + + assert result.executed_query == "complex systems thinking" diff --git a/uv.lock b/uv.lock index 5d2f786..d1b26ec 100644 --- a/uv.lock +++ b/uv.lock @@ -101,7 +101,7 @@ wheels = [ [[package]] name = "bib2graph" -version = "0.10.1" +version = "0.10.2" source = { editable = "." } dependencies = [ { name = "click" }, From a27aa09f9c93afebf71ec4eeb5c7a1babd678f66 Mon Sep 17 00:00:00 2001 From: complexluise Date: Tue, 30 Jun 2026 18:18:12 -0500 Subject: [PATCH 2/2] docs(adr): 0042 Semantic Scholar como segundo motor (Propuesta) Registra las dos decisiones que ADR 0036 difirio para el 2o motor: D2 asimetria de credenciales por rol (search exige API key, el grafo de citas no) y D3 query nativa sin traduccion. Indexa el ADR en el README de decisiones. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0042-semantic-scholar-segundo-motor.md | 129 ++++++++++++++++++ docs/decisiones/README.md | 1 + 2 files changed, 130 insertions(+) create mode 100644 docs/decisiones/0042-semantic-scholar-segundo-motor.md diff --git a/docs/decisiones/0042-semantic-scholar-segundo-motor.md b/docs/decisiones/0042-semantic-scholar-segundo-motor.md new file mode 100644 index 0000000..f06e91c --- /dev/null +++ b/docs/decisiones/0042-semantic-scholar-segundo-motor.md @@ -0,0 +1,129 @@ +# 0042 — Semantic Scholar como 2º motor de extracción: siembra exige key, forrajeo no; query nativa sin traducción + +- **Estado:** Propuesta +- **Fecha:** 2026-06-30 +- **Encuadre:** síntesis de la IA (architect) a partir de un probe empírico de la Academic Graph + API de S2 + deep-research citado; **la decisión la toma el PO**. Este ADR **no** re-decide la + intercambiabilidad de motores (ya decidida en [0036](0036-identidad-source-id-agnostica-doi-ancla.md)); + registra solo lo que es **nuevo y específico de S2** y que 0036 no resolvió. +- **Realiza:** [0036](0036-identidad-source-id-agnostica-doi-ancla.md) (que redefinió OpenAlex como + **un** motor intercambiable y **difirió explícitamente** "el hito del 2º motor (Semantic Scholar), + follow-up [#120](https://github.com/complexluise/bib2graph/issues/120)": población de `external_ids` + y selector CLI `--source`). Este ADR es ese hito. +- **Enmienda el linaje de:** [0012](0012-openalex-credenciales.md) (su principio "el primer flujo de + 10 min corre sin claves obligatorias" se cumple en OpenAlex pero **no** en la **siembra** de S2 — + ver D2). +- **Refuerza:** [0018](0018-source-agnostico-calidad.md) (otra `Source` que solo se acopla en la + costura, sin tocar el núcleo) y [0007](0007-openalex-backbone.md) (S2, antes "costura futura + opcional", entra como 2º backbone real). + +## Contexto + +ADR 0036 desacopló el núcleo del nombre del motor y dejó la infraestructura lista para enchufar un +2º motor: `_compute_id` ancla en DOI (`doi > source_id > tt`), el `source_id` es genérico, el motor +vive en `provenance.source`, y existe la tabla lateral `external_ids(paper_id, engine, id)` (infra +ya en `backends/base.py`: `add_external_id`, `external_ids_for`, `all_external_ids`). Lo que 0036 +difirió "hasta que exista un 2º motor" llegó: **Semantic Scholar (S2)**. + +Hechos del probe empírico (Academic Graph API, base `https://api.semanticscholar.org/graph/v1`, +gratuita) que **cambian la ecuación** respecto de OpenAlex: + +- **Asimetría de credenciales por rol.** Los endpoints de **grafo de citas** + (`/paper/{id}/references`, `/paper/{id}/citations`, `POST /paper/batch`) responden **200 sin key**. + Pero `/paper/search` da **429 sostenido sin key** (aun con backoff de 8 s). Es decir: el rol + **sembrador** de S2 **prácticamente exige** la API key (gratuita, carril ~1 RPS); el rol + **forrajeador/materializador** funciona **sin key** a bajo volumen. En OpenAlex ambos roles corren + sin key (ADR 0012). Esto **dobla** —no rompe— el principio de 0012 y hay que hacerlo **consciente**. +- **Sintaxis de búsqueda propia.** S2 **no** es WoS y **no** comparte la sintaxis de OpenAlex: no hay + capa de traducción WoS→S2 análoga a `_translate` de OpenAlex. Hay que **decidir** cómo se mapea la + ecuación del usuario. +- **El grafo de citas viene directo.** `/paper/{id}/citations` devuelve los **papers citantes** como + objetos (`citingPaper`), con paridad de campos. No hace falta el truco de OpenAlex + (`cites:W1|W2|…` + cruzar `references_id` del citante para atribuir). Es una divergencia de + **implementación** del forward chaining, no de contrato. +- **Identidad cross-motor gratis.** S2 expone `externalIds.DOI`. Un paper de S2 con DOI obtiene el + **mismo `id`** que en OpenAlex (D1' de 0036) → **deduplica cross-motor sin trabajo extra**, y su + `paperId` se puede registrar en `external_ids(engine="semanticscholar")`. + +> Nota de frontera (PO): la **licencia de los datos** del proveedor no es criterio de bib2graph (es +> del consumidor, Atalaya). Este ADR es solo contrato técnico; el motor es agnóstico a la fuente. + +## Decisión + +### D1 — `SemanticScholarSource` implementa `Source` (paridad de costura con OpenAlex) + +Una `Source` nueva en `src/bib2graph/sources/semanticscholar.py` que cumple el Protocol `Source` +(`seed`/`load`) y entrega el **mínimo universal** (ADR 0018: `id`, `title`, `year`, `authors_raw`, +`keywords_raw`) más el enriquecimiento que S2 sí trae (`doi`, `source_id`=`paperId`, `references_id`, +`references_doi`, `cited_by_id`, `abstract`). Replica los métodos **duck-typed** que el `Forager` +consume por `hasattr` —`fetch_citing_batch` / `fetch_citing_batch_with_works`— y +`fetch_works_by_ids`. El **núcleo no se toca** (criterio PRD §10 / 0018). El `paperId` de S2 se mapea +a `source_id` **en el límite** del adaptador; el motor se registra como `provenance.source = +"semanticscholar"`. + +### D2 — Credenciales: misma política inyectada que 0012, con asimetría declarada por rol + +`SemanticScholarSource` acepta `api_key` **inyectada** (argumento → entorno `S2_API_KEY` → ausencia), +**nunca embebida** (lección 1, igual que 0012). Asimetría **declarada y avisada** (lección 7): + +- **`seed()` sin key** → el 429 de `/paper/search` aflora como **`NetworkError`** (exit 4) accionable + que nombra el remedio: **obtener una API key gratuita de S2** (carril dedicado). No se reintenta en + silencio hasta el infinito: paralelo al `_MSG_RATE_LIMIT_429` de OpenAlex, pero el remedio es la + key, no el polite pool. +- **forrajeo/materialización sin key** → funciona a bajo volumen; con key, mejor rate limit. + +Esto **dobla** el principio de 0012 ("primer flujo de 10 min sin key obligatoria"): para S2 la +siembra sí la pide. Es un trade-off **consciente**, no una degradación silenciosa. + +### D3 — La ecuación se pasa **nativa** a S2; el `translation_report` lo declara honesto + +No se construye una capa de traducción WoS→S2. `seed(query)` pasa la `query` como **sintaxis nativa +de S2** a `/paper/search` (relevance). `SeedResult.executed_query` = la query exacta enviada; +`SeedResult.translation_report` declara **explícitamente** que **no hubo traducción** (la ecuación se +interpreta con la sintaxis de S2, distinta de WoS y de OpenAlex) — misma honestidad de "consciencia +de traducción" de 0007, aplicada al caso "sin traducción". *(Una traducción WoS→S2 más rica, si se +quiere, es trabajo futuro separado; no es de este hito.)* + +### D4 — Población de `external_ids` (cierra la mitad de #120) + +Cuando S2 entrega `paperId` y el paper tiene DOI (mismo `id` cross-motor), el adaptador registra +`external_ids(paper_id, engine="semanticscholar", id=paperId)` usando la infra ya existente. Esto es +lo que 0036 difirió "sin consumidor con OpenAlex único"; S2 es el consumidor. *(Cablear la +**escritura** de `external_ids` puede recortarse a un PR aparte si el primer PR queda grande — ver +Consecuencias; lo que NO se difiere es que el `paperId` viva en `source_id`.)* + +### D5 — Fuera de este ADR / hito: el selector CLI `--source` + +El **selector `--source `** (D-CLI de 0036, la otra mitad de #120) **no** entra acá. Hoy el +CLI instancia `OpenAlexSource` hardcodeado en 4 comandos (`seed`/`enrich`/`chain`/`build`). Exponer el +motor en la CLI es su **propio PR** (toca contrato `docs/API.md` → puede requerir su propia +enmienda/ADR de superficie CLI, linaje 0037/0038) y depende de que esta `Source` exista primero. Una +idea por PR (regla del repo). + +## Consecuencias + +- **(+) S2 entra como 2º backbone real** sin tocar el núcleo: valida empíricamente la tesis de 0036 + (motores intercambiables) y de 0018 (acople solo en la costura). Si 0036 funcionó, agregar S2 es + "una `Source` nueva". +- **(+) Dedup cross-motor gratis** vía DOI (D1' de 0036): un paper con DOI traído por OpenAlex y por + S2 colapsa al mismo `id`; ambos `paperId`/`openalex_id` quedan en `external_ids` (D4). +- **(− contrato, diferido) `docs/API.md` §2** gana una 2ª `Source` documentada (mínimo vs + enriquecimiento que S2 cubre, política de credenciales D2). El **selector `--source`** y su cambio + de convención CLI quedan para el PR de D5. +- **(− consciencia) La siembra de S2 pide key.** Hay que documentarlo en `--help`/README/API cuando + llegue, y **testear ambos caminos** (con y sin key) contra API mockeada, sin red en CI. +- **(riesgo) Divergencias S2↔OpenAlex** que el coder debe cuidar (no son decisiones, son cuidado de + implementación): IDs de citantes vienen directos (no por cruce de `references_id`); `paperId` es + hash propio, no URL; consulta por DOI vía prefijo `DOI:10.…`; `keywords` de S2 es pobre/ausente + (puede caer a `fieldsOfStudy`/`s2FieldsOfStudy` → `research_areas`/`keywords_raw`, declararlo); + paginación y límites de campos por endpoint distintos (`batch` ≤500 vs `cites:` ≤50 de OpenAlex). + +## Alternativas + +- **No hacer ADR, basta el contrato `Source` + 0036.** Rechazada: 0036 decidió la *intercambiabilidad* + pero **no** la asimetría de credenciales por rol (D2, que dobla 0012) ni el mapeo de query nativa + (D3). Son decisiones reales nuevas; sin registrarlas, el coder las improvisa y se pierde el "porqué". +- **Capa de traducción WoS→S2 en este hito.** Rechazada por alcance: infla el PR y mezcla dos ideas. + La query nativa + reporte honesto (D3) entrega valor ya; la traducción rica es trabajo futuro. +- **Meter el selector `--source` en el mismo PR.** Rechazada: dos ideas (motor nuevo + cambio de + superficie CLI/contrato). Viola "una idea por PR" y mezcla un cambio de `docs/API.md` con la costura. diff --git a/docs/decisiones/README.md b/docs/decisiones/README.md index 70ed0f1..9213001 100644 --- a/docs/decisiones/README.md +++ b/docs/decisiones/README.md @@ -90,3 +90,4 @@ Qué se vuelve posible/fácil y qué se vuelve costoso/imposible. Trade-offs hon | [0039](0039-skill-comando-meta-distribucion.md) | Distribución de la skill de Claude Code end-user: `b2g skill add [--user\|--project] [--force]` (comando **meta**; **fuera** del set de 10, no compite con `status`); skill vendoreada en el wheel (`src/bib2graph/skill/`, fuente commiteada incluida por `packages` — **no** `force-include`) → **version-lock skill==cli**; envelope `--json` sin FSM, sin workspace. **Descarta** el extra `[skill]` (no escribe en `~/.claude/skills/`) y plugin+marketplace como primario (desacopla versionado; ruta futura) | **Aceptada** (2026-06-28). **Enmienda [0038](0038-destino-verbos-huerfanos-0037.md)** (excepción meta); relacionada 0037/0028/0010/0021/0029; no introduce IA (0022). Epic [#188](https://github.com/complexluise/bib2graph/issues/188). **Nota (2026-06-28):** el [0040](0040-retiro-gui-local.md) retira `gui`, así que `skill` queda como **única** excepción meta (conteo "10 + skill") | | [0040](0040-retiro-gui-local.md) | Retirar la GUI local de la librería (BREAKING): se eliminan `b2g gui`, la API local FastAPI (`api/`), la SPA `frontend/`, el extra `[gui]` y el vendoreo del frontend en el wheel; el core es CLI/agente-native sobre la biblioteca viva. **La capa `service/` (incl. `reads.py`) se conserva** (la usa el CLI). Conteo: "10 verbos + `skill`" | **Aceptada** (2026-06-28). **Supersede [0027](0027-pivote-posicionamiento-gui-local.md)/[0028](0028-arquitectura-gui-api-capa-servicios.md)**; enmienda [0038](0038-destino-verbos-huerfanos-0037.md) (retira la excepción `gui`); relacionada 0010/0021/0037/0039/0005/0019/0032/0033/0034; no introduce IA (0022). Issue [#190](https://github.com/complexluise/bib2graph/issues/190); limpieza profunda de docs en [#191](https://github.com/complexluise/bib2graph/issues/191) | | [0041](0041-documentacion-por-superficie-de-entrega.md) | Organizar la documentación por **superficie de entrega**, no por tipo de lector: los tres usuarios = **dos superficies + un mediador** (agente dentro del repo → `AGENTS.md`/código; humano-y-agente navegando → sitio Diátaxis; IA externa → `llms.txt`). **No se forkea el sitio por audiencia**; se publica `llms.txt` (derivado de `AGENTS.md` + referencia CLI) en la raíz del sitio; persona = **veneer de navegación** (3 puertas por intención, 1 cuerpo); `AGENTS.md` sube al nav. La operación-por-IA es objetivo de 1ª clase | **Aceptada** (2026-06-29). Net-new (ningún ADR gobernaba la estructura de la doc); **no cambia contratos** (envelope/exit/FSM intactos). Relacionada 0039 (skill = doc operativa mediada por IA, fuera del sitio)/0010/0021/0037/0038/0040; no introduce IA (0022) | +| [0042](0042-semantic-scholar-segundo-motor.md) | Semantic Scholar como **2º motor de extracción**: `SemanticScholarSource` implementa `Source` (núcleo intacto); **la siembra exige API key** (429 sostenido en `/paper/search` sin key) mientras forrajeo/materialización corren sin key — asimetría por rol que **dobla 0012**; la ecuación se pasa **nativa** a S2 (sin traducción WoS, `translation_report` honesto); `paperId`→`source_id`, DOI ancla la identidad cross-motor (dedup gratis), `external_ids(engine="semanticscholar")`. **Fuera del hito:** selector CLI `--source` (PR propio) | **Propuesta** (2026-06-30). **Realiza [0036](0036-identidad-source-id-agnostica-doi-ancla.md)** (2º motor diferido, #120); enmienda el linaje de [0012](0012-openalex-credenciales.md) (siembra S2 sí pide key); refuerza 0018/0007. Encuadre IA (architect), decide el PO |