feat(ingest): recover missing metadata + abstracts from arXiv/S2/Crossref (DQ-2)

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>
This commit is contained in:
Tarik Moussa
2026-06-17 00:12:59 +02:00
parent 51dc74470c
commit ff03443af4
9 changed files with 521 additions and 20 deletions

View File

@@ -124,3 +124,46 @@ def test_fetch_recommendations_404_returns_empty(
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