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.
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/.gitignore +0 -3
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/CHANGELOG.md +24 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/PKG-INFO +1 -1
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/examples/example.py +12 -10
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/pyproject.toml +1 -1
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/__init__.py +6 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/output_converter.py +4 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/search_function.py +174 -35
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/smart_batching.py +324 -222
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_rate_limiter.py +37 -1
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_validation.py +99 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/uv.lock +1 -1
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/LICENSE +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/Makefile +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/README.md +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/docs/smart-batching-architecture.md +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/examples/example_overlapping.py +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/smart_batching_config.py +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/__init__.py +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_config.py +0 -0
- {bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/tests/test_output_converter.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
59
|
-
universe=['54F271'],
|
|
58
|
+
universe=universe_csv,
|
|
59
|
+
#universe=['54F271'],
|
|
60
60
|
start_date="2025-01-01",
|
|
61
|
-
end_date="2025-12-
|
|
62
|
-
text="
|
|
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=
|
|
66
|
-
min_entities_per_basket=
|
|
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
|
|
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=
|
|
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
|
|
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" }]
|
{bigdata_smart_batching-1.0.2 → bigdata_smart_batching-1.2.0}/src/bigdata_smart_batching/__init__.py
RENAMED
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
concurrency_limiter
|
|
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 = []
|