Spaces:
Running
Running
Step 4A-6-C-R3.2.8.2 Acronym Boundary Authenticated S2 Global Rate Limit Fix
Browse files- config.py +6 -4
- core/pipeline_agent.py +96 -86
config.py
CHANGED
|
@@ -7,10 +7,12 @@ def load_config():
|
|
| 7 |
"lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")),
|
| 8 |
"lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
|
| 9 |
"lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
|
| 10 |
-
# R3.2.
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
"
|
|
|
|
|
|
|
| 14 |
"citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
|
| 15 |
"citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
|
| 16 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
|
|
|
| 7 |
"lit_target_papers": int(os.environ.get("LIT_TARGET_PAPERS", "80")),
|
| 8 |
"lit_per_page": int(os.environ.get("LIT_PER_PAGE", "100")),
|
| 9 |
"lit_max_workers": int(os.environ.get("LIT_MAX_WORKERS", "5")),
|
| 10 |
+
# R3.2.8.2: OpenAlex is primary; Semantic Scholar is a bounded
|
| 11 |
+
# authenticated auxiliary source. Every S2 HTTP attempt across all
|
| 12 |
+
# endpoints must share one >=1 RPS gate.
|
| 13 |
+
"s2_min_interval_seconds": max(1.0, float(os.environ.get("S2_MIN_INTERVAL_SECONDS", "1.10"))),
|
| 14 |
+
"s2_result_limit": max(1, min(50, int(os.environ.get("S2_RESULT_LIMIT", "20")))),
|
| 15 |
+
"s2_max_logical_searches": max(1, min(6, int(os.environ.get("S2_MAX_LOGICAL_SEARCHES", "3")))),
|
| 16 |
"citation_planner_batch_size": max(8, min(24, int(os.environ.get("CITATION_PLANNER_BATCH_SIZE", "18")))),
|
| 17 |
"citation_planner_max_tokens": max(1200, min(3200, int(os.environ.get("CITATION_PLANNER_MAX_TOKENS", "2200")))),
|
| 18 |
"chunk_words_default": int(os.environ.get("CHUNK_WORDS_DEFAULT", "1500")),
|
core/pipeline_agent.py
CHANGED
|
@@ -66,8 +66,9 @@ class SSciPipelineAgent:
|
|
| 66 |
# exposed to the process as environment variables; Streamlit is not a
|
| 67 |
# dependency of the executor or pipeline.
|
| 68 |
self.s2_key = str(os.environ.get("S2_API_KEY", s2_key) or "").strip()
|
| 69 |
-
self.
|
| 70 |
-
self.
|
|
|
|
| 71 |
self.openalex_key = str(os.environ.get("OA_API_KEY", "") or "").strip()
|
| 72 |
self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
|
| 73 |
|
|
@@ -92,21 +93,25 @@ class SSciPipelineAgent:
|
|
| 92 |
"key_present": bool(self.s2_key) if source == "s2" else bool(self.openalex_key),
|
| 93 |
"authenticated_status": None,
|
| 94 |
"anonymous_status": None,
|
| 95 |
-
"access_mode":
|
| 96 |
}
|
| 97 |
for source in ("s2", "oa")
|
| 98 |
}
|
| 99 |
self._cancel_checker = None
|
| 100 |
self._execution_progress_hook = None
|
| 101 |
-
# S2
|
| 102 |
-
#
|
| 103 |
-
#
|
| 104 |
-
#
|
| 105 |
-
|
| 106 |
-
self.
|
| 107 |
-
self._s2_probe_complete = self.s2_anonymous_first or not bool(self.s2_key)
|
| 108 |
self._s2_request_lock = threading.Lock()
|
|
|
|
|
|
|
| 109 |
self._s2_last_request_at = 0.0
|
|
|
|
|
|
|
|
|
|
| 110 |
self.s2_fused = False
|
| 111 |
self.oa_fused = False
|
| 112 |
self.citation_map = {}
|
|
@@ -267,7 +272,9 @@ class SSciPipelineAgent:
|
|
| 267 |
"citation_audit_log": list(self.citation_audit_log),
|
| 268 |
"global_memory": self.global_memory,
|
| 269 |
"s2_access_mode": self._s2_access_mode,
|
| 270 |
-
"
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
def load_state(self, state_dict):
|
|
@@ -286,16 +293,18 @@ class SSciPipelineAgent:
|
|
| 286 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 287 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 288 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 289 |
-
if self.
|
| 290 |
-
|
| 291 |
-
|
|
|
|
| 292 |
else:
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
| 296 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 297 |
|
| 298 |
-
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None):
|
| 299 |
"""GET with bounded, status-aware retry semantics.
|
| 300 |
|
| 301 |
Authentication and query errors are returned immediately. Rate limits
|
|
@@ -308,6 +317,9 @@ class SSciPipelineAgent:
|
|
| 308 |
last_error = None
|
| 309 |
for attempt in range(attempts):
|
| 310 |
self._check_cancelled()
|
|
|
|
|
|
|
|
|
|
| 311 |
try:
|
| 312 |
response = requests.get(
|
| 313 |
url,
|
|
@@ -1628,51 +1640,68 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1628 |
self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
|
| 1629 |
return merged
|
| 1630 |
|
| 1631 |
-
def
|
| 1632 |
-
"""
|
| 1633 |
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
"""
|
| 1638 |
-
normalized_headers = dict(headers or {})
|
| 1639 |
-
anonymous = not bool(normalized_headers.get('x-api-key'))
|
| 1640 |
-
if not anonymous:
|
| 1641 |
-
return self._requests_get_with_retry(
|
| 1642 |
-
f"{self.s2_api_url}/paper/search", headers=normalized_headers, params=params
|
| 1643 |
-
)
|
| 1644 |
with self._s2_request_lock:
|
| 1645 |
elapsed = time.monotonic() - float(self._s2_last_request_at or 0.0)
|
| 1646 |
-
wait = max(0.0, self.
|
| 1647 |
if wait > 0:
|
|
|
|
| 1648 |
time.sleep(wait)
|
| 1649 |
-
response = self._requests_get_with_retry(
|
| 1650 |
-
f"{self.s2_api_url}/paper/search", headers={}, params=params
|
| 1651 |
-
)
|
| 1652 |
self._s2_last_request_at = time.monotonic()
|
| 1653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1654 |
|
| 1655 |
def _s2_worker(self, term, offset):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1656 |
self._check_cancelled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1657 |
query = self._normalize_search_query(term)
|
| 1658 |
if not query:
|
| 1659 |
self._record_source_error('s2', 'S2_BAD_QUERY')
|
| 1660 |
return []
|
|
|
|
| 1661 |
params = {
|
| 1662 |
'query': query,
|
| 1663 |
'offset': offset,
|
| 1664 |
-
'limit':
|
| 1665 |
'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
|
| 1666 |
}
|
| 1667 |
-
if self._s2_access_mode == 'blocked':
|
| 1668 |
-
# The task already proved that both keyed and anonymous access are
|
| 1669 |
-
# refused from this runtime. Avoid repeating doomed network calls;
|
| 1670 |
-
# OpenAlex remains the safe literature fallback.
|
| 1671 |
-
self._record_source_error('s2', 'S2_ACCESS_BLOCKED', self.source_metrics['s2'].get('last_status_code'))
|
| 1672 |
-
return []
|
| 1673 |
-
self._source_metric_add('s2', 'request_count')
|
| 1674 |
self._source_metric_set('s2', 'status', 'running')
|
| 1675 |
-
logging.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1676 |
try:
|
| 1677 |
access_mode = self._s2_access_mode
|
| 1678 |
headers = {'x-api-key': self.s2_key} if access_mode == 'key' and self.s2_key else {}
|
|
@@ -1683,47 +1712,21 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1683 |
self._source_metric_set('s2', 'authenticated_status', status)
|
| 1684 |
else:
|
| 1685 |
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1686 |
-
|
| 1687 |
-
|
| 1688 |
-
#
|
| 1689 |
-
#
|
| 1690 |
-
|
| 1691 |
-
|
| 1692 |
-
|
| 1693 |
-
|
| 1694 |
-
anonymous = self._s2_get(params=params, headers={})
|
| 1695 |
-
anonymous_status = int(anonymous.status_code)
|
| 1696 |
-
self._source_metric_set('s2', 'anonymous_status', anonymous_status)
|
| 1697 |
-
logging.warning(
|
| 1698 |
-
'S2 access diagnostic: key_present=true authenticated_status=%s anonymous_status=%s',
|
| 1699 |
-
status, anonymous_status,
|
| 1700 |
-
)
|
| 1701 |
-
if anonymous_status not in (401, 403):
|
| 1702 |
-
self._s2_access_mode = 'anonymous'
|
| 1703 |
-
self._source_metric_set('s2', 'access_mode', 'anonymous')
|
| 1704 |
-
response = anonymous
|
| 1705 |
-
status = anonymous_status
|
| 1706 |
-
self._source_metric_set('s2', 'last_status_code', status)
|
| 1707 |
-
else:
|
| 1708 |
-
self._s2_access_mode = 'blocked'
|
| 1709 |
-
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1710 |
-
elif self._s2_access_mode == 'anonymous':
|
| 1711 |
-
self._source_metric_add('s2', 'request_count')
|
| 1712 |
-
response = self._s2_get(params=params, headers={})
|
| 1713 |
-
status = int(response.status_code)
|
| 1714 |
-
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1715 |
-
self._source_metric_set('s2', 'last_status_code', status)
|
| 1716 |
-
if self._s2_access_mode == 'blocked' or status in (401, 403):
|
| 1717 |
-
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1718 |
-
return []
|
| 1719 |
-
elif status in (401, 403):
|
| 1720 |
-
# Anonymous access itself is refused from this runtime.
|
| 1721 |
-
if access_mode == 'anonymous':
|
| 1722 |
-
self._s2_access_mode = 'blocked'
|
| 1723 |
-
self._source_metric_set('s2', 'access_mode', 'blocked')
|
| 1724 |
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1725 |
return []
|
| 1726 |
if status == 429:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
self._record_source_error('s2', 'S2_RATE_LIMIT', status)
|
| 1728 |
return []
|
| 1729 |
if status == 400:
|
|
@@ -1776,7 +1779,10 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1776 |
})
|
| 1777 |
self._source_metric_add('s2', 'eligible_result_count', len(results))
|
| 1778 |
self._source_metric_set('s2', 'status', 'ok' if results else 'no_results')
|
| 1779 |
-
if
|
|
|
|
|
|
|
|
|
|
| 1780 |
self._source_metric_set('s2', 'last_safe_error', 'S2_NO_RESULTS')
|
| 1781 |
return results
|
| 1782 |
except PipelineCancelledError:
|
|
@@ -1786,6 +1792,8 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1786 |
except Exception as error:
|
| 1787 |
status = getattr(getattr(error, 'response', None), 'status_code', None)
|
| 1788 |
code = 'S2_RATE_LIMIT' if status == 429 else ('S2_AUTH_ERROR' if status in (401, 403) else 'S2_REQUEST_ERROR')
|
|
|
|
|
|
|
| 1789 |
self._record_source_error('s2', code, status, error)
|
| 1790 |
return []
|
| 1791 |
|
|
@@ -1920,9 +1928,11 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1920 |
self.source_metrics[source]['status'] = 'not_started'
|
| 1921 |
self.source_metrics[source]['authenticated_status'] = None
|
| 1922 |
self.source_metrics[source]['anonymous_status'] = None
|
| 1923 |
-
self._s2_access_mode = '
|
| 1924 |
-
self._s2_probe_complete = self.s2_anonymous_first or not bool(self.s2_key)
|
| 1925 |
self._s2_last_request_at = 0.0
|
|
|
|
|
|
|
|
|
|
| 1926 |
self.source_metrics['s2']['key_present'] = bool(self.s2_key)
|
| 1927 |
self.source_metrics['s2']['access_mode'] = self._s2_access_mode
|
| 1928 |
self.s2_calls = self.oa_calls = 0
|
|
@@ -1947,7 +1957,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 1947 |
future_sources = {}
|
| 1948 |
with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
|
| 1949 |
if not self.s2_fused:
|
| 1950 |
-
offset =
|
| 1951 |
for term in terms:
|
| 1952 |
future_sources[executor.submit(self._s2_worker, term, offset)] = 's2'
|
| 1953 |
if not self.oa_fused:
|
|
@@ -3216,7 +3226,7 @@ Output only the revised Discussion body, without the '# Discussion' heading."""
|
|
| 3216 |
# Internal technical hyphens are allowed (GPT-4, PLS-SEM, COVID-19),
|
| 3217 |
# but a grammatical hyphen before lowercase prose is not part of the
|
| 3218 |
# acronym (AI-powered -> AI; LLM-assisted -> LLM).
|
| 3219 |
-
return r'
|
| 3220 |
|
| 3221 |
@classmethod
|
| 3222 |
def _translation_acronym_tokens(cls, value):
|
|
|
|
| 66 |
# exposed to the process as environment variables; Streamlit is not a
|
| 67 |
# dependency of the executor or pipeline.
|
| 68 |
self.s2_key = str(os.environ.get("S2_API_KEY", s2_key) or "").strip()
|
| 69 |
+
self.s2_min_interval_seconds = float(APP_CONFIG.get("s2_min_interval_seconds", 1.10))
|
| 70 |
+
self.s2_result_limit = int(APP_CONFIG.get("s2_result_limit", 20))
|
| 71 |
+
self.s2_max_logical_searches = int(APP_CONFIG.get("s2_max_logical_searches", 3))
|
| 72 |
self.openalex_key = str(os.environ.get("OA_API_KEY", "") or "").strip()
|
| 73 |
self.openalex_email = str(APP_CONFIG.get("openalex_email") or "").strip()
|
| 74 |
|
|
|
|
| 93 |
"key_present": bool(self.s2_key) if source == "s2" else bool(self.openalex_key),
|
| 94 |
"authenticated_status": None,
|
| 95 |
"anonymous_status": None,
|
| 96 |
+
"access_mode": "key" if source == "s2" and self.s2_key else "anonymous" if source == "s2" else "api",
|
| 97 |
}
|
| 98 |
for source in ("s2", "oa")
|
| 99 |
}
|
| 100 |
self._cancel_checker = None
|
| 101 |
self._execution_progress_hook = None
|
| 102 |
+
# S2 is task-local and auxiliary. A configured key is always used
|
| 103 |
+
# first; no username/email is required and the secret is never logged.
|
| 104 |
+
# All S2 HTTP attempts (including retries/future endpoints) share one
|
| 105 |
+
# request-start gate. Logical S2 searches are also serialized so a
|
| 106 |
+
# positive auxiliary result can stop later routine S2 work.
|
| 107 |
+
self._s2_access_mode = "key" if self.s2_key else "anonymous"
|
|
|
|
| 108 |
self._s2_request_lock = threading.Lock()
|
| 109 |
+
self._s2_http_lock = threading.Lock()
|
| 110 |
+
self._s2_worker_lock = threading.Lock()
|
| 111 |
self._s2_last_request_at = 0.0
|
| 112 |
+
self._s2_logical_searches = 0
|
| 113 |
+
self._s2_aux_satisfied = False
|
| 114 |
+
self._s2_disabled = False
|
| 115 |
self.s2_fused = False
|
| 116 |
self.oa_fused = False
|
| 117 |
self.citation_map = {}
|
|
|
|
| 272 |
"citation_audit_log": list(self.citation_audit_log),
|
| 273 |
"global_memory": self.global_memory,
|
| 274 |
"s2_access_mode": self._s2_access_mode,
|
| 275 |
+
"s2_logical_searches": int(self._s2_logical_searches),
|
| 276 |
+
"s2_aux_satisfied": bool(self._s2_aux_satisfied),
|
| 277 |
+
"s2_disabled": bool(self._s2_disabled),
|
| 278 |
}
|
| 279 |
|
| 280 |
def load_state(self, state_dict):
|
|
|
|
| 293 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 294 |
self.global_memory = state_dict.get("global_memory", self.global_memory)
|
| 295 |
loaded_mode = str(state_dict.get("s2_access_mode") or "").strip().lower()
|
| 296 |
+
expected_mode = "key" if self.s2_key else "anonymous"
|
| 297 |
+
if loaded_mode in {"key", "anonymous", "blocked"}:
|
| 298 |
+
# A newly configured key supersedes a legacy anonymous checkpoint.
|
| 299 |
+
self._s2_access_mode = expected_mode if self.s2_key else loaded_mode
|
| 300 |
else:
|
| 301 |
+
self._s2_access_mode = expected_mode
|
| 302 |
+
self._s2_logical_searches = max(0, int(state_dict.get("s2_logical_searches", self._s2_logical_searches)))
|
| 303 |
+
self._s2_aux_satisfied = bool(state_dict.get("s2_aux_satisfied", self._s2_aux_satisfied))
|
| 304 |
+
self._s2_disabled = bool(state_dict.get("s2_disabled", self._s2_disabled))
|
| 305 |
logging.info(f"🔄 状态恢复成功: DS API={self.ds_calls}, 现有文献={len(self.citation_map)}")
|
| 306 |
|
| 307 |
+
def _requests_get_with_retry(self, url, headers=None, params=None, timeout=None, before_request=None):
|
| 308 |
"""GET with bounded, status-aware retry semantics.
|
| 309 |
|
| 310 |
Authentication and query errors are returned immediately. Rate limits
|
|
|
|
| 317 |
last_error = None
|
| 318 |
for attempt in range(attempts):
|
| 319 |
self._check_cancelled()
|
| 320 |
+
if before_request is not None:
|
| 321 |
+
before_request()
|
| 322 |
+
self._check_cancelled()
|
| 323 |
try:
|
| 324 |
response = requests.get(
|
| 325 |
url,
|
|
|
|
| 1640 |
self.oa_calls = int(self.source_metrics['oa']['eligible_result_count'])
|
| 1641 |
return merged
|
| 1642 |
|
| 1643 |
+
def _s2_before_http_attempt(self):
|
| 1644 |
+
"""Reserve one global S2 request-start slot and count the HTTP attempt.
|
| 1645 |
|
| 1646 |
+
The configured Semantic Scholar entitlement is cumulative across all
|
| 1647 |
+
endpoints. This gate therefore lives below search/JIT logic and is
|
| 1648 |
+
invoked before *every* HTTP attempt, including retries.
|
| 1649 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
with self._s2_request_lock:
|
| 1651 |
elapsed = time.monotonic() - float(self._s2_last_request_at or 0.0)
|
| 1652 |
+
wait = max(0.0, float(self.s2_min_interval_seconds) - elapsed)
|
| 1653 |
if wait > 0:
|
| 1654 |
+
self._check_cancelled()
|
| 1655 |
time.sleep(wait)
|
|
|
|
|
|
|
|
|
|
| 1656 |
self._s2_last_request_at = time.monotonic()
|
| 1657 |
+
self._source_metric_add('s2', 'request_count')
|
| 1658 |
+
|
| 1659 |
+
def _s2_get(self, *, params, headers=None, endpoint='/paper/search'):
|
| 1660 |
+
"""Issue one logical S2 request through the global 1-RPS gateway."""
|
| 1661 |
+
normalized_headers = dict(headers or {})
|
| 1662 |
+
path = '/' + str(endpoint or '/paper/search').lstrip('/')
|
| 1663 |
+
with self._s2_http_lock:
|
| 1664 |
+
return self._requests_get_with_retry(
|
| 1665 |
+
f"{self.s2_api_url}{path}",
|
| 1666 |
+
headers=normalized_headers,
|
| 1667 |
+
params=params,
|
| 1668 |
+
before_request=self._s2_before_http_attempt,
|
| 1669 |
+
)
|
| 1670 |
|
| 1671 |
def _s2_worker(self, term, offset):
|
| 1672 |
+
# Serialize complete logical searches. This is deliberately separate
|
| 1673 |
+
# from the HTTP-attempt gate: OpenAlex can still run concurrently, while
|
| 1674 |
+
# queued S2 searches observe the positive-result/disable state before
|
| 1675 |
+
# sending another request.
|
| 1676 |
+
with self._s2_worker_lock:
|
| 1677 |
+
return self._s2_worker_serial(term, offset)
|
| 1678 |
+
|
| 1679 |
+
def _s2_worker_serial(self, term, offset):
|
| 1680 |
self._check_cancelled()
|
| 1681 |
+
if self._s2_aux_satisfied:
|
| 1682 |
+
return []
|
| 1683 |
+
if self._s2_disabled or self._s2_access_mode == 'blocked':
|
| 1684 |
+
return []
|
| 1685 |
+
if self._s2_logical_searches >= int(self.s2_max_logical_searches):
|
| 1686 |
+
return []
|
| 1687 |
+
|
| 1688 |
query = self._normalize_search_query(term)
|
| 1689 |
if not query:
|
| 1690 |
self._record_source_error('s2', 'S2_BAD_QUERY')
|
| 1691 |
return []
|
| 1692 |
+
self._s2_logical_searches += 1
|
| 1693 |
params = {
|
| 1694 |
'query': query,
|
| 1695 |
'offset': offset,
|
| 1696 |
+
'limit': int(self.s2_result_limit),
|
| 1697 |
'fields': 'paperId,title,authors,year,venue,abstract,externalIds',
|
| 1698 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1699 |
self._source_metric_set('s2', 'status', 'running')
|
| 1700 |
+
logging.info(
|
| 1701 |
+
'S2 search: term=%r normalized=%r offset=%s access_mode=%s logical=%s/%s limit=%s',
|
| 1702 |
+
term, query, offset, self._s2_access_mode,
|
| 1703 |
+
self._s2_logical_searches, self.s2_max_logical_searches, self.s2_result_limit,
|
| 1704 |
+
)
|
| 1705 |
try:
|
| 1706 |
access_mode = self._s2_access_mode
|
| 1707 |
headers = {'x-api-key': self.s2_key} if access_mode == 'key' and self.s2_key else {}
|
|
|
|
| 1712 |
self._source_metric_set('s2', 'authenticated_status', status)
|
| 1713 |
else:
|
| 1714 |
self._source_metric_set('s2', 'anonymous_status', status)
|
| 1715 |
+
|
| 1716 |
+
if status in (401, 403):
|
| 1717 |
+
# With a configured key, do not silently switch to public access:
|
| 1718 |
+
# a bad/revoked secret should be diagnosable. OpenAlex remains
|
| 1719 |
+
# the paper-level fallback and generation continues.
|
| 1720 |
+
self._s2_access_mode = 'blocked'
|
| 1721 |
+
self._s2_disabled = True
|
| 1722 |
+
self._source_metric_set('s2', 'access_mode', 'blocked')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1723 |
self._record_source_error('s2', 'S2_AUTH_ERROR', status)
|
| 1724 |
return []
|
| 1725 |
if status == 429:
|
| 1726 |
+
# The bounded HTTP retry helper already honored Retry-After and
|
| 1727 |
+
# sent every retry through the global gate. Stop S2 for this
|
| 1728 |
+
# task after persistent rate limiting; OpenAlex continues.
|
| 1729 |
+
self._s2_disabled = True
|
| 1730 |
self._record_source_error('s2', 'S2_RATE_LIMIT', status)
|
| 1731 |
return []
|
| 1732 |
if status == 400:
|
|
|
|
| 1779 |
})
|
| 1780 |
self._source_metric_add('s2', 'eligible_result_count', len(results))
|
| 1781 |
self._source_metric_set('s2', 'status', 'ok' if results else 'no_results')
|
| 1782 |
+
if results:
|
| 1783 |
+
self._s2_aux_satisfied = True
|
| 1784 |
+
self._source_metric_set('s2', 'last_safe_error', '')
|
| 1785 |
+
else:
|
| 1786 |
self._source_metric_set('s2', 'last_safe_error', 'S2_NO_RESULTS')
|
| 1787 |
return results
|
| 1788 |
except PipelineCancelledError:
|
|
|
|
| 1792 |
except Exception as error:
|
| 1793 |
status = getattr(getattr(error, 'response', None), 'status_code', None)
|
| 1794 |
code = 'S2_RATE_LIMIT' if status == 429 else ('S2_AUTH_ERROR' if status in (401, 403) else 'S2_REQUEST_ERROR')
|
| 1795 |
+
if status in (401, 403, 429):
|
| 1796 |
+
self._s2_disabled = True
|
| 1797 |
self._record_source_error('s2', code, status, error)
|
| 1798 |
return []
|
| 1799 |
|
|
|
|
| 1928 |
self.source_metrics[source]['status'] = 'not_started'
|
| 1929 |
self.source_metrics[source]['authenticated_status'] = None
|
| 1930 |
self.source_metrics[source]['anonymous_status'] = None
|
| 1931 |
+
self._s2_access_mode = 'key' if self.s2_key else 'anonymous'
|
|
|
|
| 1932 |
self._s2_last_request_at = 0.0
|
| 1933 |
+
self._s2_logical_searches = 0
|
| 1934 |
+
self._s2_aux_satisfied = False
|
| 1935 |
+
self._s2_disabled = False
|
| 1936 |
self.source_metrics['s2']['key_present'] = bool(self.s2_key)
|
| 1937 |
self.source_metrics['s2']['access_mode'] = self._s2_access_mode
|
| 1938 |
self.s2_calls = self.oa_calls = 0
|
|
|
|
| 1957 |
future_sources = {}
|
| 1958 |
with concurrent.futures.ThreadPoolExecutor(max_workers=self.lit_max_workers) as executor:
|
| 1959 |
if not self.s2_fused:
|
| 1960 |
+
offset = int(self.s2_result_limit) if second_page else 0
|
| 1961 |
for term in terms:
|
| 1962 |
future_sources[executor.submit(self._s2_worker, term, offset)] = 's2'
|
| 1963 |
if not self.oa_fused:
|
|
|
|
| 3226 |
# Internal technical hyphens are allowed (GPT-4, PLS-SEM, COVID-19),
|
| 3227 |
# but a grammatical hyphen before lowercase prose is not part of the
|
| 3228 |
# acronym (AI-powered -> AI; LLM-assisted -> LLM).
|
| 3229 |
+
return r'(?<![A-Za-z0-9_])[A-Z]{2,}[A-Z0-9]*(?:-[A-Z0-9]+)*(?:s|es)?(?![A-Za-z0-9_])'
|
| 3230 |
|
| 3231 |
@classmethod
|
| 3232 |
def _translation_acronym_tokens(cls, value):
|