bigdata-smart-batching 1.0.2__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/.gitignore +0 -3
  2. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/CHANGELOG.md +24 -0
  3. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/PKG-INFO +1 -1
  4. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/examples/example.py +12 -10
  5. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/pyproject.toml +1 -1
  6. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/__init__.py +6 -0
  7. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/output_converter.py +4 -0
  8. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/search_function.py +174 -35
  9. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/smart_batching.py +324 -222
  10. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_rate_limiter.py +37 -1
  11. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_validation.py +99 -0
  12. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/uv.lock +1 -1
  13. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/LICENSE +0 -0
  14. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/Makefile +0 -0
  15. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/README.md +0 -0
  16. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/docs/smart-batching-architecture.md +0 -0
  17. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/examples/example_overlapping.py +0 -0
  18. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/smart_batching_config.py +0 -0
  19. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/__init__.py +0 -0
  20. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_config.py +0 -0
  21. {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_output_converter.py +0 -0
@@ -110,6 +110,3 @@ Thumbs.db
110
110
  *.csv
111
111
 
112
112
  benchmark/
113
- benchmark.py
114
- scatter_date_relevance.py
115
- simple.py
@@ -5,6 +5,30 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-05-12
9
+
10
+ ### Added
11
+
12
+ - Document-level **sentiment** filters: inclusive `min` / `max` range(s), validated with `validate_sentiment_range`, modeled as `SentimentRange` / `SentimentRangeInput`, serialized to `query.filters.sentiment.ranges` on co-mention, volume, and search payloads. `plan_search(..., sentiment=...)` and `sentiment_filter` plumbed through `SmartBatchingPlanner` co-mention and volume flows. Types and validator re-exported from the package root.
13
+ - **Planning throughput controls**: `plan_search(..., requests_per_minute=..., max_workers=...)` and `SmartBatchingPlanner(..., requests_per_minute=..., max_workers=..., rate_limiter=..., concurrency_limiter=...)` so all planner HTTP work shares one sliding-window rate limiter and one concurrency semaphore across internal thread pools.
14
+ - **`exponential_backoff`** helper in `search_function` (used by retry paths; capped via `MAX_RETRY_BACKOFF_SECONDS`).
15
+
16
+ ### Changed
17
+
18
+ - **Retries:** co-mention / volume POST retries now use **shared exponential backoff** for `429`, `403`, timeouts, and other failures, instead of parsing `Retry-After` for `429` and separate ad-hoc `403` handling (constant renamed/clamped to `MAX_RETRY_BACKOFF_SECONDS`).
19
+ - **Volume time-series merge:** when aggregating batch results per date, a **`sentiment`** value on each point is the arithmetic mean of batch sentiments for that date (batches without sentiment for that date are omitted from the mean).
20
+ - **`SlidingWindowRateLimiter`:** window accounting aligned with **ceil-based** `max_per_window` expectations (tests updated for low rates and 450 rpm-style cases).
21
+
22
+
23
+
24
+ ## [1.1.0] - 2026-04-20
25
+
26
+ ### Added
27
+
28
+ - `convert_to_dataframe` now includes a `detections` column: for each chunk row,
29
+ entity detections are taken from `chunk["detections"]` (entries with
30
+ `type == "entity"`); rows without chunks use an empty list.
31
+
8
32
  ## [1.0.2] - 2026-04-17
9
33
 
10
34
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bigdata-smart-batching
3
- Version: 1.0.2
3
+ Version: 1.2.0
4
4
  Summary: High-performance semantic search with intelligent company grouping and parallel execution
5
5
  Project-URL: homepage, https://bigdata.com/api
6
6
  Author-email: "Bigdata.com" <support@ravenpack.com>
@@ -55,21 +55,23 @@ def main() -> None:
55
55
  plan = load_plan("plan.json")
56
56
  else:
57
57
  plan = plan_search(
58
- #universe=universe_csv,
59
- universe=['54F271'],
58
+ universe=universe_csv,
59
+ #universe=['54F271'],
60
60
  start_date="2025-01-01",
61
- end_date="2025-12-12",
62
- text="Decline in customer confidence in the company",
61
+ end_date="2025-12-31",
62
+ text="Company has significant pricing power",
63
63
  volume_query_mode="iterative",
64
64
  apply_volume_splits=True,
65
- min_period_days=30,
66
- min_entities_per_basket=10,
65
+ min_period_days=120,
66
+ min_entities_per_basket=20,
67
67
  # Optional: filter by category to restrict document sources.
68
68
  # Accepts a CategoryFilter instance or a plain dict:
69
69
  category={"mode": "INCLUDE", "values": ["news_premium"]},
70
70
  content_diversification=False,
71
+ sentiment=[{"min": -1, "max": -0.5},{"min": 0.5, "max": 1}],
71
72
  )
72
73
  plans.append(plan)
74
+ save_plan(plan, "plan.json")
73
75
  print(f"Chunk upper bound estimate: {plan['chunk_upper_bound_estimate']:,}")
74
76
 
75
77
  # Export plan
@@ -78,12 +80,12 @@ def main() -> None:
78
80
  print(f"Plan execution time: {plan_elapsed_s:.3f}s")
79
81
 
80
82
  # Step 2: Execute search with 10% of total chunks (preserves distribution)
81
- if True:
83
+ if False:
82
84
  search_t0 = time.perf_counter()
83
85
  results_raw = execute_search(
84
86
  search_plan=plan,
85
87
  chunk_percentage=1,
86
- requests_per_minute=400,
88
+ requests_per_minute=300,
87
89
  )
88
90
  search_elapsed_s = time.perf_counter() - search_t0
89
91
  print(f"Search execution time: {search_elapsed_s:.3f}s")
@@ -108,10 +110,10 @@ def main() -> None:
108
110
  #print(df.head())
109
111
  #results_dfs.append(results)
110
112
  # Merge dataframe 0 and 1 into a single dataframe using id and chunk index as keys using a full outer join
111
- print(plans[0]==plans[1])
113
+ #print(plans[0]==plans[1])
112
114
  #merged = results_dfs[0].merge(results_dfs[1], on=['doc_id', 'chunk_cnum'], how='outer', indicator=True)
113
115
  #print(f"% overlapping chunks: {(len(merged[merged._merge!='both'])/2)/len(merged)*100:.2f}%")
114
- print('a')
116
+ #print('a')
115
117
 
116
118
 
117
119
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bigdata-smart-batching"
3
- version = "1.0.2"
3
+ version = "1.2.0"
4
4
  description = "High-performance semantic search with intelligent company grouping and parallel execution"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Bigdata.com", email = "support@ravenpack.com" }]
@@ -13,6 +13,8 @@ from .search_function import (
13
13
  CategoryFilter,
14
14
  CategoryFilterInput,
15
15
  CategoryMode,
16
+ SentimentRange,
17
+ SentimentRangeInput,
16
18
  UniverseInput,
17
19
  build_comention_payload,
18
20
  build_search_query,
@@ -25,12 +27,15 @@ from .search_function import (
25
27
  resolve_universe,
26
28
  save_plan,
27
29
  validate_category_filter,
30
+ validate_sentiment_range,
28
31
  )
29
32
 
30
33
  __all__ = [
31
34
  "CategoryFilter",
32
35
  "CategoryFilterInput",
33
36
  "CategoryMode",
37
+ "SentimentRange",
38
+ "SentimentRangeInput",
34
39
  "UniverseInput",
35
40
  "VALID_CATEGORY_VALUES",
36
41
  "build_comention_payload",
@@ -45,4 +50,5 @@ __all__ = [
45
50
  "resolve_universe",
46
51
  "save_plan",
47
52
  "validate_category_filter",
53
+ "validate_sentiment_range",
48
54
  ]
@@ -54,6 +54,7 @@ def convert_to_dataframe(raw_results: list[dict]) -> pd.DataFrame:
54
54
  "chunk_sentiment": None,
55
55
  # Entity
56
56
  "entity_ids": [],
57
+ "detections": [],
57
58
  # Final
58
59
  "url": url,
59
60
  "reporting_entities": reporting_entities,
@@ -62,6 +63,8 @@ def convert_to_dataframe(raw_results: list[dict]) -> pd.DataFrame:
62
63
  else:
63
64
  for chunk in chunks:
64
65
  # Entity IDs (added by execute_search)
66
+ detections = chunk.get("detections", [])
67
+ entity_detections = [d for d in detections if d.get("type") == "entity"]
65
68
  entity_ids = chunk.get("entity_ids", [])
66
69
 
67
70
  rows.append(
@@ -80,6 +83,7 @@ def convert_to_dataframe(raw_results: list[dict]) -> pd.DataFrame:
80
83
  "chunk_sentiment": chunk.get("sentiment"),
81
84
  # Entity
82
85
  "entity_ids": entity_ids,
86
+ "detections": entity_detections,
83
87
  # Final
84
88
  "url": url,
85
89
  "reporting_entities": reporting_entities,
@@ -9,6 +9,7 @@ This module provides a two-step system for efficient semantic search:
9
9
  import csv
10
10
  import json
11
11
  import logging
12
+ import math
12
13
  import os
13
14
  import sys
14
15
  import threading
@@ -43,9 +44,11 @@ SEARCH_ENDPOINT = "/v1/search"
43
44
  COMENTION_ENDPOINT = "/v1/search/co-mentions/entities"
44
45
  MAX_ENTITIES_IN_ANY_OF = 500
45
46
  MAX_CHUNKS_PER_BASKET = 1000
46
- DEFAULT_REQUESTS_PER_MINUTE = 100
47
+ DEFAULT_REQUESTS_PER_MINUTE = int(os.getenv("BIGDATA_REQUESTS_PER_MINUTE", "450"))
47
48
  DEFAULT_MAX_WORKERS = 40
48
49
  DEFAULT_WINDOW_SIZE_SECONDS = 5
50
+ DEFAULT_RETRY_BACKOFF_SECONDS = 1.0
51
+ MAX_RETRY_BACKOFF_SECONDS = 60.0
49
52
 
50
53
  UniverseInput: TypeAlias = str | list[str]
51
54
 
@@ -95,7 +98,21 @@ class CategoryFilter:
95
98
  return {"mode": self.mode.value, "values": list(self.values)}
96
99
 
97
100
 
101
+ @dataclass(frozen=True)
102
+ class SentimentRange:
103
+ """Document-level sentiment filter as an inclusive min/max range."""
104
+
105
+ min: float
106
+ max: float
107
+
108
+ def to_dict(self) -> dict[str, float]:
109
+ """Serialize to one range object inside ``query.filters.sentiment.ranges``."""
110
+ return {"min": float(self.min), "max": float(self.max)}
111
+
112
+
98
113
  CategoryFilterInput: TypeAlias = CategoryFilter | dict[str, str | list[str]]
114
+ SentimentOneInput: TypeAlias = SentimentRange | dict[str, float | int]
115
+ SentimentRangeInput: TypeAlias = SentimentOneInput | list[SentimentOneInput]
99
116
 
100
117
 
101
118
  def validate_category_filter(
@@ -143,6 +160,53 @@ def validate_category_filter(
143
160
  return {"mode": mode_str, "values": values}
144
161
 
145
162
 
163
+ def _validate_one_sentiment_range(item: SentimentOneInput) -> dict[str, float]:
164
+ """Validate a single inclusive min/max range."""
165
+ if isinstance(item, SentimentRange):
166
+ lo, hi = item.min, item.max
167
+ elif isinstance(item, dict):
168
+ if "min" not in item or "max" not in item:
169
+ raise ValueError("sentiment dict must include 'min' and 'max' keys")
170
+ lo, hi = item["min"], item["max"]
171
+ else:
172
+ raise TypeError(
173
+ f"each sentiment range must be a SentimentRange or dict, got {type(item).__name__}"
174
+ )
175
+
176
+ if not isinstance(lo, (int, float)) or isinstance(lo, bool):
177
+ raise TypeError(f"sentiment min must be a number, got {type(lo).__name__}")
178
+ if not isinstance(hi, (int, float)) or isinstance(hi, bool):
179
+ raise TypeError(f"sentiment max must be a number, got {type(hi).__name__}")
180
+
181
+ lo_f, hi_f = float(lo), float(hi)
182
+ if lo_f > hi_f:
183
+ raise ValueError(f"sentiment min ({lo_f}) must be <= max ({hi_f})")
184
+
185
+ return {"min": lo_f, "max": hi_f}
186
+
187
+
188
+ def validate_sentiment_range(sentiment: SentimentRangeInput) -> list[dict[str, float]]:
189
+ """Validate and normalize sentiment range(s) for API payloads.
190
+
191
+ Accepts a single ``SentimentRange`` or ``{"min", "max"}`` dict, or a non-empty
192
+ list of those (multiple disjoint ranges).
193
+
194
+ Returns:
195
+ List of ``{"min", "max"}`` dicts — the ``ranges`` array for
196
+ ``query.filters.sentiment``.
197
+
198
+ Raises:
199
+ TypeError: If *sentiment* is not a supported type or a list contains invalid
200
+ elements.
201
+ ValueError: If a dict is missing keys, bounds are invalid, or the list is empty.
202
+ """
203
+ if isinstance(sentiment, list):
204
+ if not sentiment:
205
+ raise ValueError("sentiment list must not be empty")
206
+ return [_validate_one_sentiment_range(x) for x in sentiment]
207
+ return [_validate_one_sentiment_range(sentiment)]
208
+
209
+
146
210
  class SlidingWindowRateLimiter:
147
211
  """
148
212
  Thread-safe sliding window rate limiter with burst prevention.
@@ -166,7 +230,7 @@ class SlidingWindowRateLimiter:
166
230
  self.max_requests = max_requests
167
231
  self.period_seconds = period_seconds
168
232
  self.window_size = window_size
169
- self.max_per_window = int(max_requests * window_size / period_seconds)
233
+ self.max_per_window = max(1, math.ceil(max_requests * window_size / period_seconds))
170
234
 
171
235
  # Track requests in sliding windows
172
236
  self.request_times = deque()
@@ -467,6 +531,7 @@ def build_comention_payload(
467
531
  end_iso: str,
468
532
  limit: int,
469
533
  category_filter: dict[str, str | list[str]] | None = None,
534
+ sentiment_filter: list[dict[str, float]] | None = None,
470
535
  ) -> dict:
471
536
  """Build a co-mention API request payload.
472
537
 
@@ -478,6 +543,8 @@ def build_comention_payload(
478
543
  end_iso: ISO-formatted end timestamp (e.g. ``2024-12-31T23:59:59Z``).
479
544
  limit: Maximum number of entities the API should return.
480
545
  category_filter: Optional category filter dict with ``mode`` and ``values``.
546
+ sentiment_filter: Optional list of ``{"min", "max"}`` ranges; emitted as
547
+ ``filters.sentiment.ranges``.
481
548
 
482
549
  Returns:
483
550
  Complete payload dict ready to be passed as ``json=`` to ``requests.post``.
@@ -493,6 +560,8 @@ def build_comention_payload(
493
560
  }
494
561
  if category_filter is not None:
495
562
  filters["category"] = category_filter
563
+ if sentiment_filter is not None:
564
+ filters["sentiment"] = {"ranges": sentiment_filter}
496
565
 
497
566
  query: dict = {
498
567
  "auto_enrich_filters": False,
@@ -513,6 +582,7 @@ def build_volume_payload(
513
582
  end_iso: str,
514
583
  category_filter: dict[str, str | list[str]] | None = None,
515
584
  content_diversification: bool = True,
585
+ sentiment_filter: list[dict[str, float]] | None = None,
516
586
  ) -> dict:
517
587
  """Build a volume endpoint request payload.
518
588
 
@@ -525,6 +595,8 @@ def build_volume_payload(
525
595
  category_filter: Optional category filter dict with ``mode`` and ``values``.
526
596
  content_diversification: Enable or disable content diversification in ranking
527
597
  (default ``True``).
598
+ sentiment_filter: Optional list of ``{"min", "max"}`` ranges; emitted as
599
+ ``filters.sentiment.ranges``.
528
600
 
529
601
  Returns:
530
602
  Complete payload dict ready to be passed as ``json=`` to ``requests.post``.
@@ -540,6 +612,8 @@ def build_volume_payload(
540
612
  }
541
613
  if category_filter is not None:
542
614
  filters["category"] = category_filter
615
+ if sentiment_filter is not None:
616
+ filters["sentiment"] = {"ranges": sentiment_filter}
543
617
 
544
618
  query: dict = {
545
619
  "auto_enrich_filters": False,
@@ -559,6 +633,7 @@ def build_search_query(
559
633
  max_chunks: int,
560
634
  category_filter: dict[str, str | list[str]] | None = None,
561
635
  content_diversification: bool = True,
636
+ sentiment_filter: list[dict[str, float]] | None = None,
562
637
  ) -> dict:
563
638
  """Build a semantic search query dict for the search endpoint.
564
639
 
@@ -575,6 +650,8 @@ def build_search_query(
575
650
  category_filter: Optional category filter dict with ``mode`` and ``values``.
576
651
  content_diversification: Enable or disable content diversification in ranking
577
652
  (default ``True``).
653
+ sentiment_filter: Optional list of ``{"min", "max"}`` ranges; emitted as
654
+ ``filters.sentiment.ranges``.
578
655
 
579
656
  Returns:
580
657
  Query dict suitable for ``make_search_request`` or embedding in a basket.
@@ -585,6 +662,8 @@ def build_search_query(
585
662
  }
586
663
  if category_filter is not None:
587
664
  filters["category"] = category_filter
665
+ if sentiment_filter is not None:
666
+ filters["sentiment"] = {"ranges": sentiment_filter}
588
667
 
589
668
  query: dict = {
590
669
  "auto_enrich_filters": False,
@@ -603,7 +682,12 @@ def build_search_query(
603
682
  return query
604
683
 
605
684
 
606
- def get_smart_batching_planner(api_key: str | None = None, api_base_url: str | None = None):
685
+ def get_smart_batching_planner(
686
+ api_key: str | None = None,
687
+ api_base_url: str | None = None,
688
+ requests_per_minute: int = DEFAULT_REQUESTS_PER_MINUTE,
689
+ max_workers: int = DEFAULT_MAX_WORKERS,
690
+ ):
607
691
  """
608
692
  Get or create SmartBatchingPlanner instance.
609
693
  Tries to import from existing module, otherwise creates a simplified version.
@@ -611,6 +695,9 @@ def get_smart_batching_planner(api_key: str | None = None, api_base_url: str | N
611
695
  Args:
612
696
  api_key: API key for Bigdata API
613
697
  api_base_url: API base URL (if provided, will set in environment for config)
698
+ requests_per_minute: Global rate limit (requests/minute) shared across all
699
+ planner API calls (comention + volume).
700
+ max_workers: Maximum concurrent in-flight requests across the planner.
614
701
 
615
702
  Returns:
616
703
  SmartBatchingPlanner instance
@@ -627,7 +714,12 @@ def get_smart_batching_planner(api_key: str | None = None, api_base_url: str | N
627
714
  try:
628
715
  from .smart_batching import SmartBatchingPlanner
629
716
 
630
- planner = SmartBatchingPlanner(api_key=api_key, api_base_url=api_base_url)
717
+ planner = SmartBatchingPlanner(
718
+ api_key=api_key,
719
+ api_base_url=api_base_url,
720
+ requests_per_minute=requests_per_minute,
721
+ max_workers=max_workers,
722
+ )
631
723
  return planner
632
724
  finally:
633
725
  # Restore original environment variable if we changed it
@@ -658,7 +750,10 @@ def plan_search(
658
750
  min_period_days: int = 30,
659
751
  min_entities_per_basket: int = 1,
660
752
  category: CategoryFilterInput | None = None,
753
+ sentiment: SentimentRangeInput | None = None,
661
754
  content_diversification: bool = True,
755
+ requests_per_minute: int = DEFAULT_REQUESTS_PER_MINUTE,
756
+ max_workers: int = DEFAULT_MAX_WORKERS,
662
757
  ) -> dict:
663
758
  """
664
759
  Plan a search using smart batching approach.
@@ -689,8 +784,19 @@ def plan_search(
689
784
  category: Optional category filter to restrict documents to specific categories.
690
785
  Accepts a ``CategoryFilter`` instance or a dict with ``mode``
691
786
  (``"INCLUDE"`` / ``"EXCLUDE"``) and ``values`` (list of category strings).
787
+ sentiment: Optional sentiment filter: one range or a list of ranges, each with
788
+ inclusive ``min`` and ``max`` (e.g. ``{"min": 0.3, "max": 1}`` or
789
+ ``[{"min": -1, "max": -0.5}, {"min": 0.5, "max": 1}]``). Accepts
790
+ ``SentimentRange`` instance(s) or dicts; emitted as
791
+ ``filters.sentiment.ranges`` on co-mention, volume, and search payloads.
692
792
  content_diversification: Enable or disable content diversification in the search
693
793
  ranking parameters (default ``True``).
794
+ requests_per_minute: Global rate limit (requests/minute) applied across all
795
+ planning API calls (comention + volume endpoints). A single
796
+ rate limiter is built once and shared by every internal worker.
797
+ max_workers: Maximum concurrent in-flight requests across all planning
798
+ API calls. A single concurrency semaphore is built once and
799
+ shared by every internal worker.
694
800
 
695
801
  Returns:
696
802
  Dict with planning results:
@@ -705,6 +811,10 @@ def plan_search(
705
811
  if category is not None:
706
812
  category_filter_dict = validate_category_filter(category)
707
813
 
814
+ sentiment_ranges: list[dict[str, float]] | None = None
815
+ if sentiment is not None:
816
+ sentiment_ranges = validate_sentiment_range(sentiment)
817
+
708
818
  api_key = api_key or os.getenv("BIGDATA_API_KEY")
709
819
  if not api_key:
710
820
  raise ValueError("API key must be provided or set in BIGDATA_API_KEY environment variable")
@@ -722,7 +832,12 @@ def plan_search(
722
832
  logger.info(f"Loaded {len(companies)} companies from universe")
723
833
 
724
834
  # Try to use SmartBatchingPlanner if available
725
- planner = get_smart_batching_planner(api_key=api_key, api_base_url=api_base_url)
835
+ planner = get_smart_batching_planner(
836
+ api_key=api_key,
837
+ api_base_url=api_base_url,
838
+ requests_per_minute=requests_per_minute,
839
+ max_workers=max_workers,
840
+ )
726
841
 
727
842
  if planner is None:
728
843
  # Simplified implementation without SmartBatchingPlanner
@@ -741,6 +856,7 @@ def plan_search(
741
856
  max_chunks=MAX_CHUNKS_PER_BASKET,
742
857
  category_filter=category_filter_dict,
743
858
  content_diversification=content_diversification,
859
+ sentiment_filter=sentiment_ranges,
744
860
  )
745
861
 
746
862
  # Estimate expected chunks (simplified - would need comention API for accurate)
@@ -782,6 +898,7 @@ def plan_search(
782
898
  min_period_days=min_period_days,
783
899
  min_entities_per_basket=min_entities_per_basket,
784
900
  category_filter=category_filter_dict,
901
+ sentiment_filter=sentiment_ranges,
785
902
  )
786
903
 
787
904
  smart = report.get("configurations", {}).get("smart", {})
@@ -815,6 +932,7 @@ def plan_search(
815
932
  max_chunks=max_chunks_for_query,
816
933
  category_filter=category_filter_dict,
817
934
  content_diversification=content_diversification,
935
+ sentiment_filter=sentiment_ranges,
818
936
  )
819
937
  basket = {
820
938
  "basket_id": basket_raw["basket_id"],
@@ -847,6 +965,7 @@ def plan_search(
847
965
  max_chunks=100,
848
966
  category_filter=category_filter_dict,
849
967
  content_diversification=content_diversification,
968
+ sentiment_filter=sentiment_ranges,
850
969
  )
851
970
  basket = {
852
971
  "basket_id": (f"basket_{global_basket_counter}_very_low_{ps_short}_{pe_short}"),
@@ -883,6 +1002,24 @@ def plan_search(
883
1002
  return plan
884
1003
 
885
1004
 
1005
+ def exponential_backoff(
1006
+ attempt: int,
1007
+ *,
1008
+ base: float = DEFAULT_RETRY_BACKOFF_SECONDS,
1009
+ cap: float = MAX_RETRY_BACKOFF_SECONDS,
1010
+ ) -> float:
1011
+ """Compute an exponential backoff delay (in seconds) for a retry attempt.
1012
+
1013
+ The delay doubles with every attempt and is clamped to ``[0, cap]``. The
1014
+ ``attempt`` argument is the zero-indexed retry number (i.e. ``0`` for the
1015
+ first retry, ``1`` for the second, ...).
1016
+ """
1017
+ if attempt < 0:
1018
+ attempt = 0
1019
+ delay = base * (2**attempt)
1020
+ return max(0.0, min(delay, cap))
1021
+
1022
+
886
1023
  def make_search_request(
887
1024
  query: dict,
888
1025
  api_key: str,
@@ -912,42 +1049,33 @@ def make_search_request(
912
1049
 
913
1050
  retry_count = 0
914
1051
  while retry_count < max_retries:
1052
+ retry_wait: float | None = None
915
1053
  try:
916
1054
  with concurrency_limiter:
917
1055
  rate_limiter.acquire(timeout=120.0)
918
-
919
1056
  response = requests.post(url, headers=headers, json=payload, timeout=120)
920
1057
 
921
- if response.status_code == 200:
922
- return response.json()
923
- elif response.status_code == 429:
924
- retry_count += 1
925
- if retry_count < max_retries:
926
- time.sleep(1.0)
927
- continue
928
- elif response.status_code == 403:
929
- retry_count += 1
930
- if retry_count < max_retries:
931
- delay = min(1.0 * (1.5 ** (retry_count // 10)), 5.0)
932
- time.sleep(delay)
933
- continue
934
- else:
935
- logger.error(f"API error {response.status_code}: {response.text[:200]}")
936
- return None
1058
+ if response.status_code == 200:
1059
+ return response.json()
1060
+ if response.status_code in (429, 403):
1061
+ retry_wait = exponential_backoff(retry_count)
1062
+ retry_count += 1
1063
+ else:
1064
+ logger.error(f"API error {response.status_code}: {response.text[:200]}")
1065
+ return None
937
1066
 
938
1067
  except requests.exceptions.Timeout:
1068
+ retry_wait = exponential_backoff(retry_count)
939
1069
  retry_count += 1
940
- if retry_count < max_retries:
941
- time.sleep(1.0)
942
- continue
943
- return None
944
1070
  except Exception as e:
945
1071
  logger.error(f"Request error: {e}")
1072
+ retry_wait = exponential_backoff(retry_count)
946
1073
  retry_count += 1
947
- if retry_count < max_retries:
948
- time.sleep(1.0)
949
- continue
950
- return None
1074
+
1075
+ if retry_count >= max_retries:
1076
+ break
1077
+ if retry_wait and retry_wait > 0:
1078
+ time.sleep(retry_wait)
951
1079
 
952
1080
  logger.warning(f"Failed after {max_retries} retries")
953
1081
  return None
@@ -1021,6 +1149,8 @@ def execute_search(
1021
1149
  max_workers: int = DEFAULT_MAX_WORKERS,
1022
1150
  overwrite_chunks_per_basket: int | None = None,
1023
1151
  second_pass: bool = False,
1152
+ rate_limiter: SlidingWindowRateLimiter | None = None,
1153
+ concurrency_limiter: ConcurrencySemaphore | None = None,
1024
1154
  ) -> list[dict]:
1025
1155
  """
1026
1156
  Execute search with proportional sampling.
@@ -1028,14 +1158,22 @@ def execute_search(
1028
1158
  Args:
1029
1159
  search_plan: Planning result from plan_search()
1030
1160
  chunk_percentage: Percentage of total chunks to retrieve (0.0 to 1.0)
1031
- requests_per_minute: Rate limit for API requests
1161
+ requests_per_minute: Rate limit for API requests. Ignored when
1162
+ ``rate_limiter`` is provided.
1032
1163
  api_key: API key (defaults to BIGDATA_API_KEY env var)
1033
1164
  api_base_url: API base URL
1034
- max_workers: Number of parallel workers
1165
+ max_workers: Number of parallel workers. Ignored when
1166
+ ``concurrency_limiter`` is provided.
1035
1167
  overwrite_chunks_per_basket: If set, override the proportional chunks per basket
1036
1168
  second_pass: If True, after the first search pass, entities already found in
1037
1169
  retrieved chunks are removed from each basket and the search is executed
1038
1170
  again with only the remaining entities. Results from both passes are combined.
1171
+ rate_limiter: Optional pre-built :class:`SlidingWindowRateLimiter` to share
1172
+ with other components (e.g. a :class:`SmartBatchingPlanner`). When
1173
+ provided, ``requests_per_minute`` is ignored.
1174
+ concurrency_limiter: Optional pre-built :class:`ConcurrencySemaphore` to
1175
+ share with other components. When provided, ``max_workers`` controls
1176
+ only the executor's worker count for basket fan-out.
1039
1177
 
1040
1178
  Returns:
1041
1179
  List of document dictionaries (each containing a chunks array).
@@ -1058,9 +1196,10 @@ def execute_search(
1058
1196
  logger.info(f"Executing search with {chunk_percentage * 100:.1f}% of chunks")
1059
1197
  logger.info(f"Total maximum expected chunks: {target_chunks:,}")
1060
1198
 
1061
- # Initialize rate limiter and concurrency limiter
1062
- rate_limiter = SlidingWindowRateLimiter(max_requests=requests_per_minute)
1063
- concurrency_limiter = ConcurrencySemaphore(max_concurrent=max_workers)
1199
+ if rate_limiter is None:
1200
+ rate_limiter = SlidingWindowRateLimiter(max_requests=requests_per_minute)
1201
+ if concurrency_limiter is None:
1202
+ concurrency_limiter = ConcurrencySemaphore(max_concurrent=max_workers)
1064
1203
 
1065
1204
  # Calculate proportional chunks per basket
1066
1205
  baskets_to_search = []