Step 4A-6-C-R3.2.8.2 Acronym Boundary Authenticated S2 Global Rate Limit Fix

#20
Files changed (2) hide show
  1. config.py +6 -4
  2. 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.7: low-volume SmartPaper uses the public Semantic Scholar path
11
- # by default. OpenAlex remains the primary resilient source.
12
- "s2_anonymous_first": os.environ.get("S2_ANONYMOUS_FIRST", "true").strip().lower() in {"1", "true", "yes", "on"},
13
- "s2_anonymous_min_interval_seconds": max(0.8, float(os.environ.get("S2_ANONYMOUS_MIN_INTERVAL_SECONDS", "1.10"))),
 
 
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.s2_anonymous_first = bool(APP_CONFIG.get("s2_anonymous_first", True))
70
- self.s2_anonymous_min_interval_seconds = float(APP_CONFIG.get("s2_anonymous_min_interval_seconds", 1.10))
 
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": ("anonymous" if self.s2_anonymous_first else "key") if source == "s2" and self.s2_key else "anonymous" if source == "s2" else "api",
96
  }
97
  for source in ("s2", "oa")
98
  }
99
  self._cancel_checker = None
100
  self._execution_progress_hook = None
101
- # S2 authentication recovery is task-local. Never log or persist the key.
102
- # "key" means use the configured key, "anonymous" means a 401/403 key
103
- # rejection was recovered by an anonymous probe, and "blocked" means
104
- # both authenticated and anonymous access were rejected.
105
- self._s2_access_mode = "anonymous" if self.s2_anonymous_first else ("key" if self.s2_key else "anonymous")
106
- self._s2_probe_lock = threading.Lock()
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
- "s2_probe_complete": bool(self._s2_probe_complete),
 
 
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.s2_anonymous_first:
290
- self._s2_access_mode = "anonymous"
291
- self._s2_probe_complete = True
 
292
  else:
293
- if loaded_mode in {"key", "anonymous", "blocked"}:
294
- self._s2_access_mode = loaded_mode
295
- self._s2_probe_complete = bool(state_dict.get("s2_probe_complete", self._s2_probe_complete))
 
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 _s2_get(self, *, params, headers=None):
1632
- """Issue an S2 request with public-endpoint pacing when anonymous.
1633
 
1634
- SmartPaper is low-volume, so one anonymous S2 request at a time is
1635
- preferable to parallel bursts that trigger the shared public 429 pool.
1636
- OpenAlex remains independently concurrent.
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.s2_anonymous_min_interval_seconds - elapsed)
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
- return response
 
 
 
 
 
 
 
 
 
 
 
 
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': 50,
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('S2 search: term=%r normalized=%r offset=%s', term, query, offset)
 
 
 
 
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
- if status in (401, 403) and access_mode == 'key' and self.s2_key:
1687
- # A configured key can be stale/revoked while the public Graph
1688
- # endpoint remains usable. Probe anonymously exactly once per
1689
- # task; this reveals only status codes, never the secret.
1690
- with self._s2_probe_lock:
1691
- if not self._s2_probe_complete:
1692
- self._s2_probe_complete = True
1693
- self._source_metric_add('s2', 'request_count')
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 not results:
 
 
 
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 = 'anonymous' if self.s2_anonymous_first else ('key' if self.s2_key else 'anonymous')
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 = 50 if second_page else 0
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'\b[A-Z]{2,}[A-Z0-9]*(?:-[A-Z0-9]+)*(?:s|es)?\b'
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):