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 | 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" },