Three papers lacked abstracts and 1911.00966 was fully degraded (OpenAlex 404 -> empty `Paper(id, title="")` stub). Recovery sources verified, then wired in: - arxiv.fetch_metadata: resolve an arXiv id to a Paper via the Atom export API (authoritative for preprints). Used as an ingest fallback on OpenAlex 404, replacing the empty stub. - crossref.py (new): fetch_abstract(doi), JATS-stripped, Crossref Polite Pool. - semanticscholar.fetch_abstract: fetch just the abstract field. - ingest.py: _recover_abstract() supplements an empty abstract from S2 then Crossref *before* embedding, so the paper gets a real (non-zero) vector. Live DB backfill: 1911.00966 fully recovered from arXiv (bibkey PinkallSpringborn2019, 384-char abstract, its 10 chunks now visible to chunk search); 10.1007/s00454-019-00132-8 abstract from S2 (1186 chars). Book chapter 10.1007/978-3-642-17413-1_7 has no abstract in OpenAlex/S2/Crossref -> documented limit. Corpus now has 1 paper without an abstract. Also documented DQ-5 (not fixed): ingest_paper is not idempotent for DOI papers (openalex returns full-URL id vs the bare canonical id) -> re-ingest trips papers_openalex_id_key. Blocks roadmap R-A/R-C; needs a careful _map_paper fix. Tests: arxiv/crossref/s2 fetchers + ingest recovery paths (342 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
"""Tests for codex.sources.semanticscholar."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from codex.models import Citation
|
|
from codex.sources import semanticscholar
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SAMPLE_REFS_RESPONSE = {
|
|
"data": [
|
|
{
|
|
"citedPaper": {
|
|
"paperId": "abc123",
|
|
"externalIds": {"DOI": "10.1000/xyz1"},
|
|
},
|
|
"contexts": ["This approach was first described in [1]."],
|
|
},
|
|
{
|
|
"citedPaper": {
|
|
"paperId": "def456",
|
|
"externalIds": {"ArXiv": "2301.07041"},
|
|
},
|
|
"contexts": [],
|
|
},
|
|
]
|
|
}
|
|
|
|
_SAMPLE_RECS_RESPONSE = {
|
|
"recommendedPapers": [
|
|
{"paperId": "rec_id_1"},
|
|
{"paperId": "rec_id_2"},
|
|
{"paperId": "rec_id_3"},
|
|
]
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_references_returns_citations(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""fetch_references maps API response to Citation objects correctly."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
return httpx.Response(200, json=_SAMPLE_REFS_RESPONSE)
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
|
|
citations = semanticscholar.fetch_references("abc123")
|
|
|
|
assert len(citations) == 2
|
|
assert all(isinstance(c, Citation) for c in citations)
|
|
|
|
# First citation uses DOI and has context
|
|
assert citations[0].citing_id == "abc123"
|
|
assert citations[0].cited_id == "10.1000/xyz1"
|
|
assert citations[0].context == "This approach was first described in [1]."
|
|
|
|
# Second citation falls back to ArXiv ID, no context
|
|
assert citations[1].citing_id == "abc123"
|
|
assert citations[1].cited_id == "2301.07041"
|
|
assert citations[1].context is None
|
|
|
|
|
|
def test_fetch_references_404_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A 404 response should return an empty list."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
raise httpx.HTTPStatusError(
|
|
"Not Found",
|
|
request=httpx.Request("GET", url),
|
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
|
)
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
|
|
result = semanticscholar.fetch_references("nonexistent")
|
|
assert result == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_recommendations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_recommendations_returns_list_of_str(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""fetch_recommendations returns a flat list of paperId strings."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
return httpx.Response(200, json=_SAMPLE_RECS_RESPONSE)
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
|
|
result = semanticscholar.fetch_recommendations("abc123", limit=3)
|
|
|
|
assert isinstance(result, list)
|
|
assert len(result) == 3
|
|
assert all(isinstance(r, str) for r in result)
|
|
assert result == ["rec_id_1", "rec_id_2", "rec_id_3"]
|
|
|
|
|
|
def test_fetch_recommendations_404_returns_empty(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A 404 response should return an empty list."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
raise httpx.HTTPStatusError(
|
|
"Not Found",
|
|
request=httpx.Request("GET", url),
|
|
response=httpx.Response(404, request=httpx.Request("GET", url)),
|
|
)
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
|
|
result = semanticscholar.fetch_recommendations("nonexistent")
|
|
assert result == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_references — {"data": null} guard (DQ-1 regression)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_references_null_data_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""S2 returns HTTP 200 with ``{"data": null}`` for a known paper that has no
|
|
parsed references; this must yield [] rather than raising TypeError."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
return httpx.Response(200, json={"data": None, "citingPaperInfo": {}})
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
assert semanticscholar.fetch_references("DOI:10.14279/depositonce-5415") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_abstract (DQ-2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_abstract_returns_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""fetch_abstract returns the abstract string when present."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
return httpx.Response(200, json={"abstract": "We study ideal hyperbolic polyhedra."})
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
assert semanticscholar.fetch_abstract("DOI:10.1007/s00454-019-00132-8") == (
|
|
"We study ideal hyperbolic polyhedra."
|
|
)
|
|
|
|
|
|
def test_fetch_abstract_null_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A null/absent abstract yields None."""
|
|
|
|
def mock_get(url: str, params: dict[str, object] | None = None) -> httpx.Response:
|
|
return httpx.Response(200, json={"abstract": None})
|
|
|
|
monkeypatch.setattr(semanticscholar, "_get", mock_get)
|
|
assert semanticscholar.fetch_abstract("arXiv:1234.5678") is None
|