mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
7752a3d730
Hardens the title-fallback match criterion across all four resolvers to exact-title-or-bust: title similarity + year/author can no longer alone promote a non-exact title to `matched`. New `exact_normalized_title` (base-OR-acronym byte-equality) + `generic_title` shared helpers; per-candidate exact gate in each client loop; `RESOLVER_DECISION_VERSION` cache gate so pre-pivot rows re-resolve. Resolver-boundary distinct-work pins flip from strict-xfail to passing assertions. Closes #431. Spec: docs/design/2026-06-13-431-title-match-hardening-spec.md (§0.12 v5 + §0.13 round-6 disposition). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
292 lines
13 KiB
Python
292 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""Minimal Semantic Scholar API client wrapper.
|
||
|
||
Implements the lookup contract documented at
|
||
`deep-research/references/semantic_scholar_api_protocol.md` for the
|
||
v3.7.3 contamination-signals migration tool (#105). DOI-first, title-
|
||
similarity fallback, 429 backoff per the protocol's retry budget.
|
||
|
||
Not a general-purpose S2 client — this is the migration tool's narrow
|
||
need (single-paper existence check). When ARS later adds a broader S2
|
||
helper, this module's `SemanticScholarClient` class should satisfy the
|
||
contamination_signals.SemanticScholarClient Protocol so the migration
|
||
tool can switch over without code changes.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from typing import Any, Mapping
|
||
|
||
# Dual-path import: try sibling-style first (scripts/-on-sys.path, used by
|
||
# tests and direct CLI invocation) so module identity matches when callers
|
||
# import via the sibling path. Fall back to `scripts.<module>` (namespace
|
||
# package style, repo-root PYTHONPATH). Same pattern as scripts/slr_lineage.py.
|
||
try:
|
||
from _text_similarity import (
|
||
_BACKOFF_SECONDS,
|
||
_MAX_RETRIES,
|
||
_TITLE_SIMILARITY_THRESHOLD,
|
||
_normalize_title,
|
||
_similarity,
|
||
exact_normalized_title,
|
||
generic_title,
|
||
)
|
||
from contamination_signals import SemanticScholarUnavailable
|
||
except ImportError:
|
||
from scripts._text_similarity import (
|
||
_BACKOFF_SECONDS,
|
||
_MAX_RETRIES,
|
||
_TITLE_SIMILARITY_THRESHOLD,
|
||
_normalize_title,
|
||
_similarity,
|
||
exact_normalized_title,
|
||
generic_title,
|
||
)
|
||
from scripts.contamination_signals import SemanticScholarUnavailable
|
||
|
||
|
||
# Per protocol: api.semanticscholar.org/graph/v1, 1 req/s unauthenticated.
|
||
_API_BASE = "https://api.semanticscholar.org/graph/v1"
|
||
_API_HOST = "api.semanticscholar.org"
|
||
_API_KEY_ENV = "S2_API_KEY"
|
||
_FIELDS = "title,authors,year,externalIds,venue,publicationDate"
|
||
|
||
# Per protocol line 6: unauthenticated tier is ~1 req/s, authenticated
|
||
# (S2_API_KEY) tier is 10 req/s. Default throttle interval defends
|
||
# against proactive rate limiting before a 429 fires (#115 R5-2).
|
||
_UNAUTHENTICATED_MIN_INTERVAL = 1.0
|
||
_AUTHENTICATED_MIN_INTERVAL = 0.1
|
||
|
||
|
||
def _require_api_url(url: str) -> None:
|
||
parsed = urllib.parse.urlsplit(url)
|
||
if parsed.scheme != "https" or parsed.netloc != _API_HOST:
|
||
raise SemanticScholarUnavailable(f"Refusing non-S2 URL: {url}")
|
||
|
||
|
||
class SemanticScholarClient:
|
||
"""Production lookup-by-(doi-then-title) client for v3.7.3 backfill.
|
||
|
||
Satisfies `contamination_signals.SemanticScholarClient` Protocol.
|
||
Tests inject MagicMocks; production callers use this concrete class.
|
||
|
||
Concurrency note: rate-limit pacing is per-instance. A migration tool
|
||
that spawns N concurrent clients will issue N × (1 req/s) outbound
|
||
even though each instance respects the throttle, blowing past the
|
||
protocol's 1 req/s unauthenticated limit and triggering 429s. Share
|
||
a single instance across a migration run.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
api_key: str | None = None,
|
||
sleep: Any = time.sleep,
|
||
clock: Any = time.monotonic,
|
||
min_interval_seconds: float | None = None,
|
||
) -> None:
|
||
self._api_key = api_key or os.environ.get(_API_KEY_ENV)
|
||
self._sleep = sleep
|
||
self._clock = clock
|
||
if min_interval_seconds is None:
|
||
# Auto-pick tier from API key presence (#115 R5-2).
|
||
self._min_interval = (
|
||
_AUTHENTICATED_MIN_INTERVAL if self._api_key
|
||
else _UNAUTHENTICATED_MIN_INTERVAL
|
||
)
|
||
else:
|
||
self._min_interval = min_interval_seconds
|
||
# Pacing state: timestamp of last request. None = no prior call.
|
||
self._last_request_at: float | None = None
|
||
# Outage latch (#115 R5-3): once URLError fires, short-circuit
|
||
# subsequent calls until reset_outage_latch() is invoked.
|
||
self._latched_unavailable: bool = False
|
||
|
||
def reset_outage_latch(self) -> None:
|
||
"""Clear the outage latch so the next lookup retries the network.
|
||
|
||
Long-running tools (e.g., per-passport-batch migration runs) can
|
||
call this between batches to attempt recovery. Per protocol
|
||
§"On API failure": network errors skip the remaining batch;
|
||
this method is the explicit "next batch starts fresh" signal.
|
||
"""
|
||
self._latched_unavailable = False
|
||
|
||
def lookup(self, entry: Mapping[str, Any]) -> Mapping[str, Any]:
|
||
"""Return {"matched": bool, "paperId": str | None}.
|
||
|
||
Failure semantics:
|
||
- HTTP 5xx → SemanticScholarUnavailable for THIS reference only
|
||
(server-side error; subsequent lookups retry normally).
|
||
- URLError (network-level) → SemanticScholarUnavailable AND
|
||
latches the client unavailable. Subsequent lookups short-
|
||
circuit without invoking urlopen until reset_outage_latch().
|
||
Per protocol §"On API failure": skip S2 for remaining batch.
|
||
|
||
Per protocol §"Query Patterns" + §"Response Handling":
|
||
`semantic_scholar_unmatched` is True only when NEITHER DOI nor
|
||
title lookup yields a hit. So:
|
||
|
||
1. If `doi` present → GET /paper/DOI:{doi}; on hit, cross-check
|
||
returned title (Levenshtein ≥ 0.70). DOI_MISMATCH (title
|
||
differs despite DOI hit) AND DOI-404 BOTH fall through to (2)
|
||
rather than returning no-match immediately — the v3.7.3
|
||
Vector 2 contract requires both DOI AND title to miss before
|
||
setting the signal. Codex R2-2 closure.
|
||
2. Title search: GET /paper/search?query={url-encoded-title};
|
||
pick the top result with title similarity ≥ 0.70, prefer
|
||
matching year.
|
||
|
||
Raises SemanticScholarUnavailable on:
|
||
- HTTP 429 after exhausting `_MAX_RETRIES` retries
|
||
- HTTP 5xx
|
||
- Network error (URLError)
|
||
"""
|
||
doi = entry.get("doi")
|
||
title = entry.get("title") or ""
|
||
if doi:
|
||
doi_result = self._lookup_by_doi(doi, title)
|
||
if doi_result["matched"]:
|
||
return doi_result
|
||
# DOI miss or DOI_MISMATCH: fall through to title search
|
||
# per protocol §Vector 2 "neither DOI nor title" rule.
|
||
if title:
|
||
return self._lookup_by_title(title, entry.get("year"))
|
||
return {"matched": False, "paperId": None}
|
||
|
||
def _request(self, path: str) -> dict[str, Any]:
|
||
# Latch short-circuit (#115 R5-3): if a prior URLError marked
|
||
# the network dead, fail fast without trying again.
|
||
if self._latched_unavailable:
|
||
raise SemanticScholarUnavailable(
|
||
"S2 API latched unavailable after prior network failure; "
|
||
"call reset_outage_latch() to retry."
|
||
)
|
||
|
||
# Throttle (#115 R5-2): pace requests at the protocol's rate
|
||
# limit. First call passes through (no prior timestamp). Update
|
||
# `_last_request_at` to "now" before issuing the request so all
|
||
# exit paths (success, 404, 5xx, HTTPError, 429-retry) leave a
|
||
# fresh anchor for the next outer call. 429 retries below
|
||
# re-update after their backoff sleep so the anchor reflects
|
||
# actual wall time even when retries land in a slow second.
|
||
if self._last_request_at is not None and self._min_interval > 0:
|
||
elapsed = self._clock() - self._last_request_at
|
||
remaining = self._min_interval - elapsed
|
||
if remaining > 0:
|
||
self._sleep(remaining)
|
||
self._last_request_at = self._clock()
|
||
|
||
url = f"{_API_BASE}{path}"
|
||
_require_api_url(url)
|
||
headers = {"User-Agent": "ARS-migration/1.0"}
|
||
if self._api_key:
|
||
headers["x-api-key"] = self._api_key
|
||
req = urllib.request.Request(url, headers=headers)
|
||
|
||
for attempt in range(_MAX_RETRIES + 1):
|
||
try:
|
||
# URL is fixed-host HTTPS after _require_api_url().
|
||
with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310
|
||
import json
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
if e.code == 404:
|
||
return {}
|
||
if e.code == 429 and attempt < _MAX_RETRIES:
|
||
self._sleep(_BACKOFF_SECONDS)
|
||
# F5 closure (simplify efficiency): refresh anchor
|
||
# after each 429 backoff so the next outer _request
|
||
# paces against actual wake time, not entry time.
|
||
# Without this the next outer call may under-sleep
|
||
# (because elapsed counts the 2s × N backoff time)
|
||
# and re-trigger 429.
|
||
self._last_request_at = self._clock()
|
||
continue
|
||
if 500 <= e.code < 600:
|
||
raise SemanticScholarUnavailable(
|
||
f"S2 API HTTP {e.code}"
|
||
) from e
|
||
raise SemanticScholarUnavailable(
|
||
f"S2 API HTTP {e.code} after {_MAX_RETRIES} retries"
|
||
) from e
|
||
except urllib.error.URLError as e:
|
||
# Network-level failure: latch the client so subsequent
|
||
# lookups in the same batch fail fast (#115 R5-3).
|
||
# Per protocol §"On API failure": skip S2 for remaining
|
||
# batch on network error.
|
||
self._latched_unavailable = True
|
||
raise SemanticScholarUnavailable(f"S2 API network error: {e}") from e
|
||
except (OSError, TimeoutError) as e:
|
||
# Response-body read timeouts (socket.timeout subclasses
|
||
# OSError; TimeoutError is the 3.10+ alias) and other
|
||
# transient I/O failures during resp.read() must be
|
||
# treated as API degradation per spec — never let them
|
||
# abort the migration. Codex R4-2 closure.
|
||
#
|
||
# Also latch the client (codex #115 R2 closure): the
|
||
# protocol §"On API failure" rule "skip S2 for remaining
|
||
# batch on network error" covers transport-level
|
||
# failures, which include connection resets / socket
|
||
# read timeouts that surface during resp.read(), not
|
||
# only URLError at urlopen() time. Without this latch,
|
||
# a real network outage produces 30s read-timeout per
|
||
# entry × N entries.
|
||
self._latched_unavailable = True
|
||
raise SemanticScholarUnavailable(
|
||
f"S2 API I/O failure during response read: {e}"
|
||
) from e
|
||
raise SemanticScholarUnavailable(f"S2 API exhausted {_MAX_RETRIES} retries")
|
||
|
||
def _lookup_by_doi(self, doi: str, expected_title: str) -> dict[str, Any]:
|
||
data = self._request(
|
||
f"/paper/DOI:{urllib.parse.quote(doi, safe='')}?fields={_FIELDS}"
|
||
)
|
||
if not data or not data.get("paperId"):
|
||
return {"matched": False, "paperId": None}
|
||
# Per protocol: cross-check title; DOI_MISMATCH counts as no-match
|
||
# for this binary signal.
|
||
returned_title = data.get("title") or ""
|
||
if _similarity(expected_title, returned_title) < _TITLE_SIMILARITY_THRESHOLD:
|
||
return {"matched": False, "paperId": None}
|
||
return {"matched": True, "paperId": data["paperId"]}
|
||
|
||
def _lookup_by_title(self, title: str, year: int | None) -> dict[str, Any]:
|
||
"""Title search under the #431 exact-title-or-bust gate.
|
||
|
||
A candidate matches iff it clears the 0.70 ratio AND is an exact
|
||
normalized title match (§0.12.1); the candidate dict must be inspected
|
||
for its title here (the pre-#431 version discarded it, keeping only
|
||
paperId, so it could not apply the exact gate). A non-exact high-ratio
|
||
title is never promoted on year alone. On the title-fallback path no ID
|
||
corroborates, so an exact-but-generic title (§0.12.2) is not promoted.
|
||
A no-exact loop returns no match → the resolver reduces the title-keyed
|
||
miss to `unresolvable` (never a false `matched`)."""
|
||
if generic_title(title):
|
||
return {"matched": False, "paperId": None}
|
||
path = (
|
||
f"/paper/search?query={urllib.parse.quote(title)}"
|
||
f"&limit=5&fields={_FIELDS}"
|
||
)
|
||
data = self._request(path)
|
||
candidates = data.get("data") or []
|
||
best: tuple[float, dict[str, Any]] | None = None
|
||
for cand in candidates:
|
||
cand_title = cand.get("title") or ""
|
||
sim = _similarity(title, cand_title)
|
||
if sim < _TITLE_SIMILARITY_THRESHOLD:
|
||
continue
|
||
if not exact_normalized_title(title, cand_title):
|
||
continue
|
||
# Per protocol: prefer matching year when multiple ≥0.70 results.
|
||
year_match = year is not None and cand.get("year") == year
|
||
score = sim + (0.05 if year_match else 0.0)
|
||
if best is None or score > best[0]:
|
||
best = (score, cand)
|
||
if best is None:
|
||
return {"matched": False, "paperId": None}
|
||
return {"matched": True, "paperId": best[1].get("paperId")}
|