feat(ingest): supplement citations from Semantic Scholar when OpenAlex is empty (DQ-1)

12/29 papers had zero citations: OpenAlex indexes arXiv preprints and theses
without a parsed reference list (verified referenced_works_count=0 server-side
for all 11 with an openalex_id). Not an ingest bug — a source-coverage limit.

- ingest.py: when OpenAlex returns no references, fall back to a Semantic
  Scholar reference supplement (_s2_reference_supplement); citing_id rewritten
  to canonical papers.id, DOI cited-ids lowercased. Removed a now-redundant
  discarded S2 probe in the OpenAlex-404 arXiv branch.
- semanticscholar.py: fix TypeError on S2's HTTP-200 {"data": null} no-refs
  responses (.get("data", []) returns None when the key is present-but-null).
- tests: regression test for the OpenAlex-empty -> S2 path.
- Live DB backfilled idempotently: +330 citations (590->920), zero-out-edge
  papers 12->2, citing coverage 17->27/29. Only the 2 TU-Berlin depositonce
  theses remain (no references in any source).
- docs: DATA-QUALITY-2026-06-15.md — DQ-1 resolution, DQ-4 result, and a
  free-source acquisition roadmap (R-A..R-E).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-06-16 23:51:48 +02:00
parent 553ae3995d
commit 51dc74470c
4 changed files with 260 additions and 16 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from pathlib import Path
@@ -47,6 +46,55 @@ def _make_bibkey(paper: Paper) -> str | None:
return surnames[0] + "EtAl" + year
def _s2_id_for(pid: str) -> str | None:
"""Format a canonical paper id for the Semantic Scholar paper endpoint.
S2 needs a namespaced id (``arXiv:<id>``, ``DOI:<doi>``) or a bare 40-hex
S2 paperId — a bare arXiv id like ``2305.10988`` 404s. DOIs are detected by
the ``10.`` prefix; everything else is assumed to be an arXiv id (modern
``2305.10988`` or legacy ``math/0603097``).
"""
p = (pid or "").strip()
if not p:
return None
if p.lower().startswith(("arxiv:", "doi:")):
return p
if p.startswith("10."):
return f"DOI:{p}"
return f"arXiv:{p}"
def _norm_cited_id(cited_id: str) -> str:
"""Lowercase DOI cited-ids (DOIs are case-insensitive) so the graph join
does not fragment the same reference into distinct case-variant nodes.
arXiv ids and S2 paperIds are left untouched."""
return cited_id.lower() if cited_id.startswith("10.") else cited_id
def _s2_reference_supplement(paper: Paper) -> list[Citation]:
"""Fetch a paper's references from Semantic Scholar (DQ-1 supplement).
Used when OpenAlex returns an empty ``referenced_works`` list — common for
arXiv preprints and institutional theses that OpenAlex indexes without a
parsed bibliography. ``citing_id`` is rewritten to the canonical ``paper.id``
so the FK holds, and cited DOIs are case-normalised. Network/parse failures
degrade to an empty list rather than aborting the ingest.
"""
s2_id = _s2_id_for(paper.id)
if s2_id is None:
return []
try:
raw = semanticscholar.fetch_references(s2_id)
except Exception:
logger.warning("S2 reference supplement failed for %s", paper.id, exc_info=True)
return []
return [
Citation(citing_id=paper.id, cited_id=_norm_cited_id(c.cited_id), context=c.context)
for c in raw
if c.cited_id
]
def ingest_paper(
paper_id: str,
source_path: str | None = None,
@@ -88,9 +136,9 @@ def ingest_paper(
len(paper_id) > 4 and paper_id[4:5] == "." and paper_id[:4].isdigit()
)
if looks_like_arxiv:
arxiv_key = paper_id if pid_lower.startswith("arxiv:") else f"arXiv:{paper_id}"
with contextlib.suppress(Exception):
semanticscholar.fetch_references(arxiv_key)
# OpenAlex 404 on an arXiv id → keep a minimal stub. Citation
# recovery from S2 happens in the citation block below (DQ-1
# supplement), so no probe fetch is needed here.
paper = Paper(id=paper_id, title="")
else:
raise ValueError(f"Paper not found: {paper_id}")
@@ -229,8 +277,13 @@ def ingest_paper(
api_citations = [
Citation(citing_id=paper.id, cited_id=c.cited_id, context=c.context) for c in raw
]
# DQ-1: OpenAlex indexes many arXiv preprints / theses WITHOUT a
# parsed reference list (referenced_works == []). Fall back to the
# Semantic Scholar references, which often has them.
if not api_citations:
api_citations = _s2_reference_supplement(paper)
else:
api_citations = semanticscholar.fetch_references(paper_id)
api_citations = _s2_reference_supplement(paper)
# Merge API citations and GROBID PDF citations; dedup via set
all_citations_set: set[tuple[str, str]] = set()