bigdata-smart-batching 0.1.0__py3-none-any.whl
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/__init__.py +28 -0
- bigdata_smart_batching/output_converter.py +96 -0
- bigdata_smart_batching/search_function.py +1071 -0
- bigdata_smart_batching/smart_batching.py +1593 -0
- bigdata_smart_batching/smart_batching_config.py +39 -0
- bigdata_smart_batching-0.1.0.dist-info/METADATA +231 -0
- bigdata_smart_batching-0.1.0.dist-info/RECORD +8 -0
- bigdata_smart_batching-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart Batching Search Function
|
|
3
|
+
|
|
4
|
+
This module provides a two-step system for efficient semantic search:
|
|
5
|
+
1. Planning: Organize search using smart batching and return total expected chunks
|
|
6
|
+
2. Execution: Perform search with proportional sampling to preserve distribution
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
# Configure logging
|
|
24
|
+
# Disable logging exceptions to prevent ZMQ socket errors in Jupyter notebooks
|
|
25
|
+
# when multiple threads log simultaneously
|
|
26
|
+
logging.raiseExceptions = False
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(
|
|
29
|
+
level=logging.INFO,
|
|
30
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
31
|
+
handlers=[logging.StreamHandler(sys.stdout)],
|
|
32
|
+
)
|
|
33
|
+
# Enable debug logging for entity extraction issues
|
|
34
|
+
# Set to DEBUG to see response structure details
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Configuration constants
|
|
38
|
+
DEFAULT_API_BASE_URL = os.getenv("BIGDATA_API_BASE_URL", "https://api.bigdata.com")
|
|
39
|
+
SEARCH_ENDPOINT = "/v1/search"
|
|
40
|
+
COMENTION_ENDPOINT = "/v1/search/co-mentions/entities"
|
|
41
|
+
MAX_ENTITIES_IN_ANY_OF = 500
|
|
42
|
+
MAX_CHUNKS_PER_BASKET = 1000
|
|
43
|
+
DEFAULT_REQUESTS_PER_MINUTE = 100
|
|
44
|
+
DEFAULT_MAX_WORKERS = 40
|
|
45
|
+
DEFAULT_WINDOW_SIZE_SECONDS = 5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SlidingWindowRateLimiter:
|
|
49
|
+
"""
|
|
50
|
+
Thread-safe sliding window rate limiter with burst prevention.
|
|
51
|
+
Uses 5-second windows to prevent request bursts.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
max_requests: int = DEFAULT_REQUESTS_PER_MINUTE,
|
|
57
|
+
period_seconds: int = 60,
|
|
58
|
+
window_size: int = DEFAULT_WINDOW_SIZE_SECONDS,
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Initialize sliding window rate limiter.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
max_requests: Maximum requests allowed per period (default: 100)
|
|
65
|
+
period_seconds: Time period in seconds (default: 60 for per-minute)
|
|
66
|
+
window_size: Size of sliding window in seconds (default: 5 for burst prevention)
|
|
67
|
+
"""
|
|
68
|
+
self.max_requests = max_requests
|
|
69
|
+
self.period_seconds = period_seconds
|
|
70
|
+
self.window_size = window_size
|
|
71
|
+
self.max_per_window = int(max_requests * window_size / period_seconds)
|
|
72
|
+
|
|
73
|
+
# Track requests in sliding windows
|
|
74
|
+
self.request_times = deque()
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
# Metrics tracking
|
|
78
|
+
self.total_requests = 0
|
|
79
|
+
self.total_wait_time = 0.0
|
|
80
|
+
self.throttle_events = 0
|
|
81
|
+
self.rate_limit_warnings = 0
|
|
82
|
+
|
|
83
|
+
def _clean_old_requests(self, current_time: float) -> None:
|
|
84
|
+
"""Remove requests outside the sliding window."""
|
|
85
|
+
cutoff_time = current_time - self.period_seconds
|
|
86
|
+
while self.request_times and self.request_times[0] < cutoff_time:
|
|
87
|
+
self.request_times.popleft()
|
|
88
|
+
|
|
89
|
+
def _requests_in_window(self, current_time: float) -> int:
|
|
90
|
+
"""Count requests in the current window."""
|
|
91
|
+
window_start = current_time - self.window_size
|
|
92
|
+
return sum(1 for t in self.request_times if t >= window_start)
|
|
93
|
+
|
|
94
|
+
def acquire(self, timeout: float = 60.0) -> float:
|
|
95
|
+
"""
|
|
96
|
+
Acquire permission to make a request, blocking if necessary.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
timeout: Maximum time to wait in seconds
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Wait time in seconds (0 if no wait was needed)
|
|
103
|
+
"""
|
|
104
|
+
start_time = time.time()
|
|
105
|
+
total_wait = 0.0
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
with self._lock:
|
|
109
|
+
current_time = time.time()
|
|
110
|
+
self._clean_old_requests(current_time)
|
|
111
|
+
|
|
112
|
+
# Check if we can make a request
|
|
113
|
+
requests_in_period = len(self.request_times)
|
|
114
|
+
requests_in_window = self._requests_in_window(current_time)
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
requests_in_period < self.max_requests
|
|
118
|
+
and requests_in_window < self.max_per_window
|
|
119
|
+
):
|
|
120
|
+
# Permission granted
|
|
121
|
+
self.request_times.append(current_time)
|
|
122
|
+
self.total_requests += 1
|
|
123
|
+
self.total_wait_time += total_wait
|
|
124
|
+
return total_wait
|
|
125
|
+
|
|
126
|
+
# Need to wait
|
|
127
|
+
self.throttle_events += 1
|
|
128
|
+
if requests_in_window >= self.max_per_window:
|
|
129
|
+
# Wait for window to clear
|
|
130
|
+
wait_time = self.window_size / 10 # Small incremental wait
|
|
131
|
+
else:
|
|
132
|
+
# Wait for period to clear
|
|
133
|
+
oldest_request = self.request_times[0]
|
|
134
|
+
wait_time = (oldest_request + self.period_seconds - current_time) + 0.1
|
|
135
|
+
|
|
136
|
+
# Check timeout
|
|
137
|
+
if time.time() - start_time > timeout:
|
|
138
|
+
raise TimeoutError("Rate limiter timeout exceeded")
|
|
139
|
+
|
|
140
|
+
# Wait outside the lock
|
|
141
|
+
time.sleep(min(wait_time, 1.0))
|
|
142
|
+
total_wait += wait_time
|
|
143
|
+
|
|
144
|
+
def get_stats(self) -> dict:
|
|
145
|
+
"""Get rate limiter statistics."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
return {
|
|
148
|
+
"total_requests": self.total_requests,
|
|
149
|
+
"total_wait_time_seconds": round(self.total_wait_time, 2),
|
|
150
|
+
"throttle_events": self.throttle_events,
|
|
151
|
+
"rate_limit_warnings": self.rate_limit_warnings,
|
|
152
|
+
"current_requests_in_period": len(self.request_times),
|
|
153
|
+
"max_requests_per_period": self.max_requests,
|
|
154
|
+
"max_requests_per_window": self.max_per_window,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ConcurrencySemaphore:
|
|
159
|
+
"""
|
|
160
|
+
Semaphore to limit simultaneous connections.
|
|
161
|
+
Prevents connection spikes that can trigger security filters.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, max_concurrent: int = DEFAULT_MAX_WORKERS):
|
|
165
|
+
"""
|
|
166
|
+
Initialize concurrency semaphore.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
max_concurrent: Maximum simultaneous connections (default: 40)
|
|
170
|
+
"""
|
|
171
|
+
self.semaphore = threading.Semaphore(max_concurrent)
|
|
172
|
+
self.max_concurrent = max_concurrent
|
|
173
|
+
self.active_count = 0
|
|
174
|
+
self._lock = threading.Lock()
|
|
175
|
+
|
|
176
|
+
# Metrics
|
|
177
|
+
self.total_acquisitions = 0
|
|
178
|
+
self.peak_concurrent = 0
|
|
179
|
+
|
|
180
|
+
def __enter__(self):
|
|
181
|
+
"""Acquire semaphore."""
|
|
182
|
+
self.semaphore.acquire()
|
|
183
|
+
with self._lock:
|
|
184
|
+
self.active_count += 1
|
|
185
|
+
self.total_acquisitions += 1
|
|
186
|
+
self.peak_concurrent = max(self.peak_concurrent, self.active_count)
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, _exc_type, _exc_val, _exc_tb):
|
|
190
|
+
"""Release semaphore."""
|
|
191
|
+
with self._lock:
|
|
192
|
+
self.active_count -= 1
|
|
193
|
+
self.semaphore.release()
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def get_stats(self) -> dict:
|
|
197
|
+
"""Get semaphore statistics."""
|
|
198
|
+
with self._lock:
|
|
199
|
+
return {
|
|
200
|
+
"active_connections": self.active_count,
|
|
201
|
+
"max_concurrent": self.max_concurrent,
|
|
202
|
+
"total_acquisitions": self.total_acquisitions,
|
|
203
|
+
"peak_concurrent": self.peak_concurrent,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_universe_from_csv(csv_path: str, id_column: str = "id") -> list[str]:
|
|
208
|
+
"""
|
|
209
|
+
Load entity IDs from CSV file.
|
|
210
|
+
|
|
211
|
+
Supports two formats:
|
|
212
|
+
1. CSV with header row containing 'id' column (e.g., id,name)
|
|
213
|
+
2. Simple CSV with one entity ID per line (no header)
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
csv_path: Path to CSV file
|
|
217
|
+
id_column: Name of the column containing entity IDs (default: 'id')
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
List of entity IDs
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
FileNotFoundError: If CSV file doesn't exist
|
|
224
|
+
ValueError: If CSV file is empty or invalid
|
|
225
|
+
"""
|
|
226
|
+
if not os.path.exists(csv_path):
|
|
227
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
228
|
+
|
|
229
|
+
companies = []
|
|
230
|
+
with open(csv_path, encoding="utf-8") as f:
|
|
231
|
+
reader = csv.reader(f)
|
|
232
|
+
first_row = next(reader, None)
|
|
233
|
+
|
|
234
|
+
if first_row is None:
|
|
235
|
+
raise ValueError(f"CSV file is empty: {csv_path}")
|
|
236
|
+
|
|
237
|
+
# Check if first row is a header containing the id_column
|
|
238
|
+
first_row_lower = [col.strip().lower() for col in first_row]
|
|
239
|
+
|
|
240
|
+
if id_column.lower() in first_row_lower:
|
|
241
|
+
# CSV has header - find the index of the id column
|
|
242
|
+
id_idx = first_row_lower.index(id_column.lower())
|
|
243
|
+
for row in reader:
|
|
244
|
+
if row and len(row) > id_idx and row[id_idx].strip():
|
|
245
|
+
companies.append(row[id_idx].strip())
|
|
246
|
+
else:
|
|
247
|
+
# No header - treat first row as data (first column contains IDs)
|
|
248
|
+
if first_row and first_row[0].strip():
|
|
249
|
+
companies.append(first_row[0].strip())
|
|
250
|
+
for row in reader:
|
|
251
|
+
if row and row[0].strip():
|
|
252
|
+
companies.append(row[0].strip())
|
|
253
|
+
|
|
254
|
+
if not companies:
|
|
255
|
+
raise ValueError(f"CSV file contains no valid entity IDs: {csv_path}")
|
|
256
|
+
|
|
257
|
+
logger.info(f"Loaded {len(companies)} entity IDs from {csv_path}")
|
|
258
|
+
return companies
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_date_format(date_str: str) -> bool:
|
|
262
|
+
"""Validate date format is YYYY-MM-DD."""
|
|
263
|
+
try:
|
|
264
|
+
datetime.strptime(date_str, "%Y-%m-%d")
|
|
265
|
+
return True
|
|
266
|
+
except ValueError:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_date_range(start_date: str, end_date: str) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Validate date range.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
start_date: Start date in YYYY-MM-DD format
|
|
276
|
+
end_date: End date in YYYY-MM-DD format
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If dates are invalid or start > end
|
|
280
|
+
"""
|
|
281
|
+
if not validate_date_format(start_date):
|
|
282
|
+
raise ValueError(f"Invalid start date format: {start_date}. Expected YYYY-MM-DD")
|
|
283
|
+
|
|
284
|
+
if not validate_date_format(end_date):
|
|
285
|
+
raise ValueError(f"Invalid end date format: {end_date}. Expected YYYY-MM-DD")
|
|
286
|
+
|
|
287
|
+
start = datetime.strptime(start_date, "%Y-%m-%d")
|
|
288
|
+
end = datetime.strptime(end_date, "%Y-%m-%d")
|
|
289
|
+
|
|
290
|
+
if start > end:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"Start date ({start_date}) must be before or equal to end date ({end_date})"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def validate_chunk_percentage(chunk_percentage: float) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Validate chunk percentage is between 0.0 and 1.0.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
chunk_percentage: Percentage value to validate
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValueError: If percentage is out of bounds
|
|
305
|
+
"""
|
|
306
|
+
if not isinstance(chunk_percentage, (int, float)):
|
|
307
|
+
raise ValueError(f"chunk_percentage must be a number, got {type(chunk_percentage)}")
|
|
308
|
+
|
|
309
|
+
if chunk_percentage < 0.0 or chunk_percentage > 1.0:
|
|
310
|
+
raise ValueError(f"chunk_percentage must be between 0.0 and 1.0, got {chunk_percentage}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def date_to_iso(date_str: str, is_start: bool = True) -> str:
|
|
314
|
+
"""
|
|
315
|
+
Convert YYYY-MM-DD date to ISO format with timezone.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
date_str: Date in YYYY-MM-DD format
|
|
319
|
+
is_start: True for start date (00:00:00), False for end date (23:59:59)
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
ISO format date string
|
|
323
|
+
"""
|
|
324
|
+
if is_start:
|
|
325
|
+
return f"{date_str}T00:00:00Z"
|
|
326
|
+
else:
|
|
327
|
+
return f"{date_str}T23:59:59Z"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_smart_batching_planner(api_key: str | None = None, api_base_url: str | None = None):
|
|
331
|
+
"""
|
|
332
|
+
Get or create SmartBatchingPlanner instance.
|
|
333
|
+
Tries to import from existing module, otherwise creates a simplified version.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
api_key: API key for Bigdata API
|
|
337
|
+
api_base_url: API base URL (if provided, will set in environment for config)
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
SmartBatchingPlanner instance
|
|
341
|
+
"""
|
|
342
|
+
try:
|
|
343
|
+
# Try to import from existing smart_batching module
|
|
344
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
345
|
+
|
|
346
|
+
# If api_base_url is provided, set it in environment so config picks it up
|
|
347
|
+
if api_base_url:
|
|
348
|
+
original_url = os.environ.get("BIGDATA_API_BASE_URL")
|
|
349
|
+
os.environ["BIGDATA_API_BASE_URL"] = api_base_url
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
from .smart_batching import SmartBatchingPlanner
|
|
353
|
+
|
|
354
|
+
planner = SmartBatchingPlanner(api_key=api_key, api_base_url=api_base_url)
|
|
355
|
+
return planner
|
|
356
|
+
finally:
|
|
357
|
+
# Restore original environment variable if we changed it
|
|
358
|
+
if api_base_url:
|
|
359
|
+
if original_url:
|
|
360
|
+
os.environ["BIGDATA_API_BASE_URL"] = original_url
|
|
361
|
+
else:
|
|
362
|
+
os.environ.pop("BIGDATA_API_BASE_URL", None)
|
|
363
|
+
except ImportError as e:
|
|
364
|
+
# If not available, we'll need to implement a simplified version inline
|
|
365
|
+
logger.warning(f"smart_batching module not found ({e}), using simplified planner")
|
|
366
|
+
return None
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.warning(f"Error initializing SmartBatchingPlanner ({e}), using simplified planner")
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def plan_search(
|
|
373
|
+
text: str,
|
|
374
|
+
universe_csv_path: str,
|
|
375
|
+
start_date: str,
|
|
376
|
+
end_date: str,
|
|
377
|
+
api_key: str | None = None,
|
|
378
|
+
api_base_url: str | None = None,
|
|
379
|
+
volume_query_mode: str = "three_pass",
|
|
380
|
+
max_iterations_per_batch: int = 10,
|
|
381
|
+
apply_volume_splits: bool = True,
|
|
382
|
+
min_period_days: int = 30,
|
|
383
|
+
min_entities_per_basket: int = 1,
|
|
384
|
+
) -> dict:
|
|
385
|
+
"""
|
|
386
|
+
Plan a search using smart batching approach.
|
|
387
|
+
|
|
388
|
+
This function organizes the search by:
|
|
389
|
+
1. Loading universe of companies from CSV
|
|
390
|
+
2. Getting comention volumes for all companies
|
|
391
|
+
3. Creating optimized baskets
|
|
392
|
+
4. Building complete query structures with text embedded
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
text: Search query text
|
|
396
|
+
universe_csv_path: Path to CSV file with entity IDs (one per line)
|
|
397
|
+
start_date: Start date in YYYY-MM-DD format
|
|
398
|
+
end_date: End date in YYYY-MM-DD format
|
|
399
|
+
api_key: API key (defaults to BIGDATA_API_KEY env var)
|
|
400
|
+
api_base_url: API base URL (defaults to env var or DEFAULT_API_BASE_URL)
|
|
401
|
+
volume_query_mode: Method for querying volumes. Options:
|
|
402
|
+
- "three_pass": Original 3-pass approach (query all, then verify twice)
|
|
403
|
+
- "iterative": Per-batch iterative approach (query batch, remove found, repeat until empty)
|
|
404
|
+
max_iterations_per_batch: Max iterations per batch when using "iterative" mode (default 10)
|
|
405
|
+
apply_volume_splits: If True (default), use volume time series to split periods per company.
|
|
406
|
+
If False, use time-based granularity and estimated sub-period volumes only.
|
|
407
|
+
min_period_days: Minimum number of days per period. When splitting by volume, we need to ensure
|
|
408
|
+
that the period is at least min_period_days days long. Default is 30 days.
|
|
409
|
+
min_entities_per_basket: Minimum number of entities per period group. Groups below this
|
|
410
|
+
threshold are merged with the nearest group by periods_needed. Default is 1
|
|
411
|
+
(no consolidation).
|
|
412
|
+
Returns:
|
|
413
|
+
Dict with planning results:
|
|
414
|
+
- total_expected_chunks: Total chunks expected
|
|
415
|
+
- baskets: List of basket configs with complete query structures
|
|
416
|
+
- planning_metadata: Additional metadata
|
|
417
|
+
"""
|
|
418
|
+
# Input validation
|
|
419
|
+
if not text or not text.strip():
|
|
420
|
+
raise ValueError("text cannot be empty")
|
|
421
|
+
|
|
422
|
+
validate_date_range(start_date, end_date)
|
|
423
|
+
|
|
424
|
+
api_key = api_key or os.getenv("BIGDATA_API_KEY")
|
|
425
|
+
if not api_key:
|
|
426
|
+
raise ValueError("API key must be provided or set in BIGDATA_API_KEY environment variable")
|
|
427
|
+
|
|
428
|
+
api_base_url = api_base_url or os.getenv("BIGDATA_API_BASE_URL", DEFAULT_API_BASE_URL)
|
|
429
|
+
|
|
430
|
+
logger.info(f"Planning search for text: '{text}'")
|
|
431
|
+
logger.info(f"Date range: {start_date} to {end_date}")
|
|
432
|
+
|
|
433
|
+
# Load universe
|
|
434
|
+
companies = load_universe_from_csv(universe_csv_path)
|
|
435
|
+
logger.info(f"Loaded {len(companies)} companies from universe")
|
|
436
|
+
|
|
437
|
+
# Try to use SmartBatchingPlanner if available
|
|
438
|
+
planner = get_smart_batching_planner(api_key=api_key, api_base_url=api_base_url)
|
|
439
|
+
|
|
440
|
+
if planner is None:
|
|
441
|
+
# Simplified implementation without SmartBatchingPlanner
|
|
442
|
+
# This creates a basic plan with all companies in one basket per period
|
|
443
|
+
logger.warning("Using simplified planning (SmartBatchingPlanner not available)")
|
|
444
|
+
|
|
445
|
+
# Create a single basket for the entire period
|
|
446
|
+
start_iso = date_to_iso(start_date, is_start=True)
|
|
447
|
+
end_iso = date_to_iso(end_date, is_start=False)
|
|
448
|
+
|
|
449
|
+
# Build query structure matching benchmark format
|
|
450
|
+
query = {
|
|
451
|
+
"auto_enrich_filters": False,
|
|
452
|
+
"text": text,
|
|
453
|
+
"filters": {
|
|
454
|
+
"timestamp": {"start": start_iso, "end": end_iso},
|
|
455
|
+
"entity": {
|
|
456
|
+
"any_of": companies[:MAX_ENTITIES_IN_ANY_OF], # Limit to API max
|
|
457
|
+
"search_in": "BODY",
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
"ranking_params": {
|
|
461
|
+
"source_boost": 1,
|
|
462
|
+
"freshness_boost": 0,
|
|
463
|
+
"reranker": {"enabled": False},
|
|
464
|
+
},
|
|
465
|
+
"max_chunks": MAX_CHUNKS_PER_BASKET,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# Estimate expected chunks (simplified - would need comention API for accurate)
|
|
469
|
+
# For now, we'll use a placeholder that can be updated
|
|
470
|
+
estimated_chunks = len(companies) * 10 # Rough estimate
|
|
471
|
+
|
|
472
|
+
basket = {
|
|
473
|
+
"basket_id": "basket_0",
|
|
474
|
+
"companies": companies[:MAX_ENTITIES_IN_ANY_OF],
|
|
475
|
+
"expected_chunks": estimated_chunks,
|
|
476
|
+
"period_start": start_date,
|
|
477
|
+
"period_end": end_date,
|
|
478
|
+
"query": query,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
plan = {
|
|
482
|
+
"total_expected_chunks": estimated_chunks,
|
|
483
|
+
"baskets": [basket],
|
|
484
|
+
"planning_metadata": {
|
|
485
|
+
"total_companies": len(companies),
|
|
486
|
+
"companies_processed": len(companies[:MAX_ENTITIES_IN_ANY_OF]),
|
|
487
|
+
"periods": 1,
|
|
488
|
+
"uses_smart_batching": False,
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
else:
|
|
492
|
+
# Use SmartBatchingPlanner with adaptive time splitting (plan_all_periods)
|
|
493
|
+
# so each query stays <= 1000 chunks by splitting the time window when needed
|
|
494
|
+
logger.info("Using SmartBatchingPlanner with adaptive time-window splitting")
|
|
495
|
+
|
|
496
|
+
report = planner.plan_all_periods(
|
|
497
|
+
topic=text,
|
|
498
|
+
start_date=start_date,
|
|
499
|
+
end_date=end_date,
|
|
500
|
+
volume_query_mode=volume_query_mode,
|
|
501
|
+
max_iterations_per_batch=max_iterations_per_batch,
|
|
502
|
+
universe_csv_path=universe_csv_path,
|
|
503
|
+
apply_volume_splits=apply_volume_splits,
|
|
504
|
+
min_period_days=min_period_days,
|
|
505
|
+
min_entities_per_basket=min_entities_per_basket,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
smart = report.get("configurations", {}).get("smart", {})
|
|
509
|
+
period_details = smart.get("period_details", [])
|
|
510
|
+
|
|
511
|
+
baskets = []
|
|
512
|
+
all_companies_in_plan = set()
|
|
513
|
+
total_expected = 0
|
|
514
|
+
|
|
515
|
+
for period_detail in period_details:
|
|
516
|
+
period_start = period_detail.get("start_date", start_date)
|
|
517
|
+
period_end = period_detail.get("end_date", end_date)
|
|
518
|
+
start_iso = date_to_iso(period_start, is_start=True)
|
|
519
|
+
end_iso = date_to_iso(period_end, is_start=False)
|
|
520
|
+
|
|
521
|
+
for basket_raw in period_detail.get("baskets", []):
|
|
522
|
+
expected_chunks = basket_raw.get("total_chunks", 0)
|
|
523
|
+
total_expected += expected_chunks
|
|
524
|
+
all_companies_in_plan.update(basket_raw.get("companies", []))
|
|
525
|
+
|
|
526
|
+
# Cap at API limit: each sub-period query can request at most 1000 chunks
|
|
527
|
+
max_chunks_for_query = (
|
|
528
|
+
min(expected_chunks, MAX_CHUNKS_PER_BASKET) if expected_chunks > 0 else 100
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
query = {
|
|
532
|
+
"auto_enrich_filters": False,
|
|
533
|
+
"text": text,
|
|
534
|
+
"filters": {
|
|
535
|
+
"timestamp": {"start": start_iso, "end": end_iso},
|
|
536
|
+
"entity": {"any_of": basket_raw["companies"], "search_in": "BODY"},
|
|
537
|
+
},
|
|
538
|
+
"ranking_params": {
|
|
539
|
+
"source_boost": 1,
|
|
540
|
+
"freshness_boost": 0,
|
|
541
|
+
"reranker": {"enabled": False},
|
|
542
|
+
},
|
|
543
|
+
"max_chunks": max_chunks_for_query,
|
|
544
|
+
}
|
|
545
|
+
basket = {
|
|
546
|
+
"basket_id": basket_raw["basket_id"],
|
|
547
|
+
"companies": basket_raw["companies"],
|
|
548
|
+
"expected_chunks": expected_chunks,
|
|
549
|
+
"period_start": period_start,
|
|
550
|
+
"period_end": period_end,
|
|
551
|
+
"query": query,
|
|
552
|
+
}
|
|
553
|
+
baskets.append(basket)
|
|
554
|
+
|
|
555
|
+
# Add very_low baskets (companies with 0 chunks in full period) for full window
|
|
556
|
+
# Continue global basket counter from where plan_all_periods left off
|
|
557
|
+
very_low_companies = [c for c in companies if c not in all_companies_in_plan]
|
|
558
|
+
if very_low_companies:
|
|
559
|
+
global_basket_counter = len(baskets) # Continue numbering
|
|
560
|
+
very_low_baskets_raw = planner.create_baskets(
|
|
561
|
+
{}, max_chunks=MAX_CHUNKS_PER_BASKET, very_low_companies=very_low_companies
|
|
562
|
+
)
|
|
563
|
+
start_iso = date_to_iso(start_date, is_start=True)
|
|
564
|
+
end_iso = date_to_iso(end_date, is_start=False)
|
|
565
|
+
for basket_raw in very_low_baskets_raw:
|
|
566
|
+
query = {
|
|
567
|
+
"auto_enrich_filters": False,
|
|
568
|
+
"text": text,
|
|
569
|
+
"filters": {
|
|
570
|
+
"timestamp": {"start": start_iso, "end": end_iso},
|
|
571
|
+
"entity": {"any_of": basket_raw["companies"], "search_in": "BODY"},
|
|
572
|
+
},
|
|
573
|
+
"ranking_params": {
|
|
574
|
+
"source_boost": 1,
|
|
575
|
+
"freshness_boost": 0,
|
|
576
|
+
"reranker": {"enabled": False},
|
|
577
|
+
},
|
|
578
|
+
"max_chunks": 100,
|
|
579
|
+
}
|
|
580
|
+
basket = {
|
|
581
|
+
"basket_id": f"very_low_basket_{global_basket_counter}",
|
|
582
|
+
"companies": basket_raw["companies"],
|
|
583
|
+
"expected_chunks": 0,
|
|
584
|
+
"period_start": start_date,
|
|
585
|
+
"period_end": end_date,
|
|
586
|
+
"query": query,
|
|
587
|
+
}
|
|
588
|
+
global_basket_counter += 1
|
|
589
|
+
baskets.append(basket)
|
|
590
|
+
|
|
591
|
+
plan = {
|
|
592
|
+
"total_expected_chunks": total_expected,
|
|
593
|
+
"baskets": baskets,
|
|
594
|
+
"planning_metadata": {
|
|
595
|
+
"total_companies": len(companies),
|
|
596
|
+
"companies_with_chunks": len(all_companies_in_plan),
|
|
597
|
+
"companies_very_low": len(very_low_companies),
|
|
598
|
+
"comention_queries": smart.get("comention_queries", 0),
|
|
599
|
+
"baskets_created": len(baskets),
|
|
600
|
+
"uses_smart_batching": True,
|
|
601
|
+
"adaptive_time_splitting": True,
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
logger.info(
|
|
606
|
+
f"Planning complete: {plan['total_expected_chunks']:,} expected chunks in {len(plan['baskets'])} baskets"
|
|
607
|
+
)
|
|
608
|
+
return plan
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def make_search_request(
|
|
612
|
+
query: dict,
|
|
613
|
+
api_key: str,
|
|
614
|
+
api_base_url: str,
|
|
615
|
+
rate_limiter: SlidingWindowRateLimiter,
|
|
616
|
+
concurrency_limiter: ConcurrencySemaphore,
|
|
617
|
+
max_retries: int = 5,
|
|
618
|
+
) -> dict | None:
|
|
619
|
+
"""
|
|
620
|
+
Make a search API request with rate limiting and retry logic.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
query: Complete query structure
|
|
624
|
+
api_key: API key
|
|
625
|
+
api_base_url: API base URL
|
|
626
|
+
rate_limiter: Rate limiter instance
|
|
627
|
+
concurrency_limiter: Concurrency limiter instance
|
|
628
|
+
max_retries: Maximum retry attempts
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
API response JSON or None if failed
|
|
632
|
+
"""
|
|
633
|
+
url = f"{api_base_url}{SEARCH_ENDPOINT}"
|
|
634
|
+
headers = {"X-API-KEY": api_key, "Content-Type": "application/json"}
|
|
635
|
+
|
|
636
|
+
payload = {"query": query}
|
|
637
|
+
|
|
638
|
+
retry_count = 0
|
|
639
|
+
while retry_count < max_retries:
|
|
640
|
+
try:
|
|
641
|
+
with concurrency_limiter:
|
|
642
|
+
rate_limiter.acquire(timeout=120.0)
|
|
643
|
+
|
|
644
|
+
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
|
645
|
+
|
|
646
|
+
if response.status_code == 200:
|
|
647
|
+
return response.json()
|
|
648
|
+
elif response.status_code == 429:
|
|
649
|
+
retry_count += 1
|
|
650
|
+
if retry_count < max_retries:
|
|
651
|
+
time.sleep(1.0)
|
|
652
|
+
continue
|
|
653
|
+
elif response.status_code == 403:
|
|
654
|
+
retry_count += 1
|
|
655
|
+
if retry_count < max_retries:
|
|
656
|
+
delay = min(1.0 * (1.5 ** (retry_count // 10)), 5.0)
|
|
657
|
+
time.sleep(delay)
|
|
658
|
+
continue
|
|
659
|
+
else:
|
|
660
|
+
logger.error(f"API error {response.status_code}: {response.text[:200]}")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
except requests.exceptions.Timeout:
|
|
664
|
+
retry_count += 1
|
|
665
|
+
if retry_count < max_retries:
|
|
666
|
+
time.sleep(1.0)
|
|
667
|
+
continue
|
|
668
|
+
return None
|
|
669
|
+
except Exception as e:
|
|
670
|
+
logger.error(f"Request error: {e}")
|
|
671
|
+
retry_count += 1
|
|
672
|
+
if retry_count < max_retries:
|
|
673
|
+
time.sleep(1.0)
|
|
674
|
+
continue
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
logger.warning(f"Failed after {max_retries} retries")
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def deduplicate_documents(documents: list[dict]) -> list[dict]:
|
|
682
|
+
"""
|
|
683
|
+
Deduplicate documents, merging chunks from duplicate documents.
|
|
684
|
+
|
|
685
|
+
When the same document appears multiple times (e.g., from different baskets
|
|
686
|
+
searching for different entities), this function merges their chunks to
|
|
687
|
+
ensure no chunk is lost.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
documents: List of document dictionaries (each containing a chunks array)
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
List of unique documents with merged chunks
|
|
694
|
+
"""
|
|
695
|
+
doc_map = {} # doc_id -> document with merged chunks
|
|
696
|
+
docs_without_id = []
|
|
697
|
+
|
|
698
|
+
for doc in documents:
|
|
699
|
+
doc_id = doc.get("id", "")
|
|
700
|
+
if not doc_id:
|
|
701
|
+
# Keep documents without ID as-is
|
|
702
|
+
docs_without_id.append(doc)
|
|
703
|
+
elif doc_id not in doc_map:
|
|
704
|
+
# First occurrence - store a copy with chunks as a list
|
|
705
|
+
doc_copy = doc.copy()
|
|
706
|
+
doc_copy["chunks"] = list(doc.get("chunks", []))
|
|
707
|
+
doc_map[doc_id] = doc_copy
|
|
708
|
+
else:
|
|
709
|
+
# Duplicate document - merge chunks, avoiding duplicates
|
|
710
|
+
existing_doc = doc_map[doc_id]
|
|
711
|
+
existing_chunks = existing_doc.get("chunks", [])
|
|
712
|
+
|
|
713
|
+
# Build set of existing chunk identifiers (using chunk_index or text hash as fallback)
|
|
714
|
+
existing_chunk_ids = set()
|
|
715
|
+
for chunk in existing_chunks:
|
|
716
|
+
chunk_id = chunk.get("chunk_index")
|
|
717
|
+
if chunk_id is None:
|
|
718
|
+
# Fallback: use hash of text content
|
|
719
|
+
chunk_id = hash(chunk.get("text", ""))
|
|
720
|
+
existing_chunk_ids.add(chunk_id)
|
|
721
|
+
|
|
722
|
+
# Add new chunks that don't already exist
|
|
723
|
+
for chunk in doc.get("chunks", []):
|
|
724
|
+
chunk_id = chunk.get("chunk_index")
|
|
725
|
+
if chunk_id is None:
|
|
726
|
+
chunk_id = hash(chunk.get("text", ""))
|
|
727
|
+
if chunk_id not in existing_chunk_ids:
|
|
728
|
+
existing_chunks.append(chunk)
|
|
729
|
+
existing_chunk_ids.add(chunk_id)
|
|
730
|
+
|
|
731
|
+
original_count = len(documents)
|
|
732
|
+
unique_documents = list(doc_map.values()) + docs_without_id
|
|
733
|
+
logger.info(
|
|
734
|
+
f"Deduplicated: {len(unique_documents)} unique documents from {original_count} total (chunks merged)"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
return unique_documents
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def execute_search(
|
|
741
|
+
search_plan: dict,
|
|
742
|
+
chunk_percentage: float,
|
|
743
|
+
requests_per_minute: int = DEFAULT_REQUESTS_PER_MINUTE,
|
|
744
|
+
api_key: str | None = None,
|
|
745
|
+
api_base_url: str | None = None,
|
|
746
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
747
|
+
overwrite_chunks_per_basket: int | None = None,
|
|
748
|
+
second_pass: bool = False,
|
|
749
|
+
) -> list[dict]:
|
|
750
|
+
"""
|
|
751
|
+
Execute search with proportional sampling.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
search_plan: Planning result from plan_search()
|
|
755
|
+
chunk_percentage: Percentage of total chunks to retrieve (0.0 to 1.0)
|
|
756
|
+
requests_per_minute: Rate limit for API requests
|
|
757
|
+
api_key: API key (defaults to BIGDATA_API_KEY env var)
|
|
758
|
+
api_base_url: API base URL
|
|
759
|
+
max_workers: Number of parallel workers
|
|
760
|
+
overwrite_chunks_per_basket: If set, override the proportional chunks per basket
|
|
761
|
+
second_pass: If True, after the first search pass, entities already found in
|
|
762
|
+
retrieved chunks are removed from each basket and the search is executed
|
|
763
|
+
again with only the remaining entities. Results from both passes are combined.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
List of document dictionaries (each containing a chunks array).
|
|
767
|
+
Note: Use deduplicate_documents() after this to merge duplicate documents.
|
|
768
|
+
"""
|
|
769
|
+
# Input validation
|
|
770
|
+
validate_chunk_percentage(chunk_percentage)
|
|
771
|
+
|
|
772
|
+
if "baskets" not in search_plan or not search_plan["baskets"]:
|
|
773
|
+
raise ValueError("Invalid search plan: missing or empty baskets")
|
|
774
|
+
|
|
775
|
+
api_key = api_key or os.getenv("BIGDATA_API_KEY")
|
|
776
|
+
if not api_key:
|
|
777
|
+
raise ValueError("API key must be provided or set in BIGDATA_API_KEY environment variable")
|
|
778
|
+
|
|
779
|
+
api_base_url = api_base_url or os.getenv("BIGDATA_API_BASE_URL", DEFAULT_API_BASE_URL)
|
|
780
|
+
|
|
781
|
+
total_expected = search_plan.get("total_expected_chunks", 0)
|
|
782
|
+
target_chunks = int(total_expected * chunk_percentage)
|
|
783
|
+
logger.info(f"Executing search with {chunk_percentage * 100:.1f}% of chunks")
|
|
784
|
+
logger.info(f"Total maximum expected chunks: {target_chunks:,}")
|
|
785
|
+
|
|
786
|
+
# Initialize rate limiter and concurrency limiter
|
|
787
|
+
rate_limiter = SlidingWindowRateLimiter(max_requests=requests_per_minute)
|
|
788
|
+
concurrency_limiter = ConcurrencySemaphore(max_concurrent=max_workers)
|
|
789
|
+
|
|
790
|
+
# Calculate proportional chunks per basket
|
|
791
|
+
baskets_to_search = []
|
|
792
|
+
for basket in search_plan["baskets"]:
|
|
793
|
+
expected = basket.get("expected_chunks", 0)
|
|
794
|
+
|
|
795
|
+
if expected > 0:
|
|
796
|
+
# Calculate proportional chunks, capped at API limit (1000)
|
|
797
|
+
proportional_chunks = min(
|
|
798
|
+
max(1, int(expected * chunk_percentage)), MAX_CHUNKS_PER_BASKET
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
# For very_low baskets (expected_chunks == 0), use the max_chunks from query or default
|
|
802
|
+
# These baskets might still return some results from the semantic search
|
|
803
|
+
proportional_chunks = basket.get("query", {}).get("max_chunks", 100)
|
|
804
|
+
|
|
805
|
+
if overwrite_chunks_per_basket is not None:
|
|
806
|
+
proportional_chunks = overwrite_chunks_per_basket
|
|
807
|
+
|
|
808
|
+
basket_query = basket["query"].copy()
|
|
809
|
+
basket_query["max_chunks"] = proportional_chunks
|
|
810
|
+
baskets_to_search.append(
|
|
811
|
+
{
|
|
812
|
+
"basket_id": basket["basket_id"],
|
|
813
|
+
"query": basket_query,
|
|
814
|
+
"expected_chunks": expected,
|
|
815
|
+
"proportional_chunks": proportional_chunks,
|
|
816
|
+
"companies": basket.get("companies", []), # Include companies for entity extraction
|
|
817
|
+
}
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
logger.info(f"Searching {len(baskets_to_search)} baskets")
|
|
821
|
+
|
|
822
|
+
# Execute searches in parallel
|
|
823
|
+
all_documents = []
|
|
824
|
+
failed_baskets = []
|
|
825
|
+
start_time = time.time()
|
|
826
|
+
basket_out = []
|
|
827
|
+
|
|
828
|
+
def search_basket(basket_info):
|
|
829
|
+
"""Search a single basket and return documents with enriched chunks."""
|
|
830
|
+
try:
|
|
831
|
+
# Get entity IDs from the basket (companies we're searching for)
|
|
832
|
+
# This is the most reliable source since we know which companies are in each basket
|
|
833
|
+
basket_entity_ids = basket_info.get("companies", [])
|
|
834
|
+
if not basket_entity_ids:
|
|
835
|
+
logger.warning(f"Basket {basket_info.get('basket_id')} has no companies list")
|
|
836
|
+
|
|
837
|
+
response = make_search_request(
|
|
838
|
+
query=basket_info["query"],
|
|
839
|
+
api_key=api_key,
|
|
840
|
+
api_base_url=api_base_url,
|
|
841
|
+
rate_limiter=rate_limiter,
|
|
842
|
+
concurrency_limiter=concurrency_limiter,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
if response and "results" in response:
|
|
846
|
+
# results is an array of documents, each with a chunks array
|
|
847
|
+
documents = response["results"]
|
|
848
|
+
total_chunks = 0
|
|
849
|
+
|
|
850
|
+
# Also try to get entity IDs from the query filters as backup
|
|
851
|
+
# Debug: Log structure of first response to understand format
|
|
852
|
+
first_doc_logged = False
|
|
853
|
+
|
|
854
|
+
for document in documents:
|
|
855
|
+
document_chunks = document.get("chunks", [])
|
|
856
|
+
total_chunks += len(document_chunks)
|
|
857
|
+
# Get document-level entity information (try both snake_case and camelCase)
|
|
858
|
+
reporting_entities = (
|
|
859
|
+
document.get("reporting_entities")
|
|
860
|
+
or document.get("reportingEntities")
|
|
861
|
+
or []
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Debug: Log first document and chunk structure
|
|
865
|
+
if not first_doc_logged and document_chunks:
|
|
866
|
+
logger.debug(f"Document keys: {list(document.keys())}")
|
|
867
|
+
logger.debug(f"First chunk keys: {list(document_chunks[0].keys())}")
|
|
868
|
+
logger.debug(
|
|
869
|
+
f"First chunk detections: {document_chunks[0].get('detections')}"
|
|
870
|
+
)
|
|
871
|
+
logger.debug(f"First chunk entities: {document_chunks[0].get('entities')}")
|
|
872
|
+
first_doc_logged = True
|
|
873
|
+
|
|
874
|
+
# Enrich each chunk with entity information
|
|
875
|
+
for chunk in document_chunks:
|
|
876
|
+
# Extract entity information from chunk
|
|
877
|
+
# Try multiple possible field names and structures
|
|
878
|
+
entity_ids = []
|
|
879
|
+
|
|
880
|
+
# Try 'detections' array (most common in REST API)
|
|
881
|
+
detections = chunk.get("detections", [])
|
|
882
|
+
if detections:
|
|
883
|
+
for d in detections:
|
|
884
|
+
# Detection can be: {"id": "...", ...} or just a string ID
|
|
885
|
+
entity_id = (
|
|
886
|
+
d.get("id")
|
|
887
|
+
if isinstance(d, dict)
|
|
888
|
+
else (d if isinstance(d, str) else None)
|
|
889
|
+
)
|
|
890
|
+
if entity_id:
|
|
891
|
+
entity_ids.append(entity_id)
|
|
892
|
+
|
|
893
|
+
# Try 'entities' array (alternative name)
|
|
894
|
+
if not entity_ids:
|
|
895
|
+
entities = chunk.get("entities", [])
|
|
896
|
+
if entities:
|
|
897
|
+
for e in entities:
|
|
898
|
+
# Entity can be: {"id": "...", "key": "...", ...} or just a string
|
|
899
|
+
entity_id = (
|
|
900
|
+
e.get("id") or e.get("key")
|
|
901
|
+
if isinstance(e, dict)
|
|
902
|
+
else (e if isinstance(e, str) else None)
|
|
903
|
+
)
|
|
904
|
+
if entity_id:
|
|
905
|
+
entity_ids.append(entity_id)
|
|
906
|
+
|
|
907
|
+
# Try document-level reporting_entities
|
|
908
|
+
if not entity_ids and reporting_entities:
|
|
909
|
+
if isinstance(reporting_entities, list):
|
|
910
|
+
entity_ids = [e for e in reporting_entities if e]
|
|
911
|
+
else:
|
|
912
|
+
entity_ids = [reporting_entities] if reporting_entities else []
|
|
913
|
+
|
|
914
|
+
# Store entity information (always set, even if empty)
|
|
915
|
+
chunk["entity_ids"] = entity_ids
|
|
916
|
+
chunk["primary_entity_id"] = entity_ids[0] if entity_ids else None
|
|
917
|
+
|
|
918
|
+
logger.info(
|
|
919
|
+
f"Basket {basket_info['basket_id']}: Retrieved {len(documents)} documents with {total_chunks} chunks"
|
|
920
|
+
)
|
|
921
|
+
return documents, basket_info["basket_id"]
|
|
922
|
+
else:
|
|
923
|
+
logger.warning(f"Basket {basket_info['basket_id']}: No results or error")
|
|
924
|
+
return [], basket_info["basket_id"]
|
|
925
|
+
except Exception as e:
|
|
926
|
+
logger.error(f"Basket {basket_info['basket_id']}: Error - {e}")
|
|
927
|
+
return [], basket_info["basket_id"]
|
|
928
|
+
|
|
929
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
930
|
+
futures = {
|
|
931
|
+
executor.submit(search_basket, basket_info): basket_info
|
|
932
|
+
for basket_info in baskets_to_search
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
completed = 0
|
|
936
|
+
for future in as_completed(futures):
|
|
937
|
+
completed += 1
|
|
938
|
+
basket_info = futures[future]
|
|
939
|
+
try:
|
|
940
|
+
documents, basket_id = future.result()
|
|
941
|
+
if documents:
|
|
942
|
+
all_documents.extend(documents)
|
|
943
|
+
basket_info["chunks_retrieved"] = len(
|
|
944
|
+
[chunk for doc in documents for chunk in doc.get("chunks", [])]
|
|
945
|
+
)
|
|
946
|
+
basket_out.append(basket_info)
|
|
947
|
+
else:
|
|
948
|
+
failed_baskets.append(basket_id)
|
|
949
|
+
except Exception as e:
|
|
950
|
+
logger.error(f"Error processing basket {basket_info['basket_id']}: {e}")
|
|
951
|
+
failed_baskets.append(basket_info["basket_id"])
|
|
952
|
+
|
|
953
|
+
if completed % 10 == 0:
|
|
954
|
+
logger.info(f"Progress: {completed}/{len(baskets_to_search)} baskets completed")
|
|
955
|
+
|
|
956
|
+
first_pass_chunks = sum(len(doc.get("chunks", [])) for doc in all_documents)
|
|
957
|
+
logger.info(
|
|
958
|
+
f"First pass complete: {len(all_documents)} documents with {first_pass_chunks} chunks"
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
if second_pass:
|
|
962
|
+
all_companies = [
|
|
963
|
+
company
|
|
964
|
+
for basket in baskets_to_search
|
|
965
|
+
for company in basket.get("query", [])
|
|
966
|
+
.get("filters", [])
|
|
967
|
+
.get("entity", [])
|
|
968
|
+
.get("any_of", [])
|
|
969
|
+
]
|
|
970
|
+
|
|
971
|
+
retrieved_entities = (
|
|
972
|
+
[
|
|
973
|
+
detection.get("id")
|
|
974
|
+
for i in all_documents
|
|
975
|
+
for chunk in i.get("chunks", [])
|
|
976
|
+
for detection in chunk.get("detections", [])
|
|
977
|
+
]
|
|
978
|
+
+ [
|
|
979
|
+
entity
|
|
980
|
+
for i in all_documents
|
|
981
|
+
for chunk in i.get("chunks", [])
|
|
982
|
+
for entity in chunk.get("entity_ids", [])
|
|
983
|
+
]
|
|
984
|
+
+ [entity for i in all_documents for entity in i.get("reporting_entities", [])]
|
|
985
|
+
+ [
|
|
986
|
+
chunk.get("primary_entity_id")
|
|
987
|
+
for i in all_documents
|
|
988
|
+
for chunk in i.get("chunks", [])
|
|
989
|
+
]
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
retrieved_entities_cleaned = set([e for e in retrieved_entities if e in all_companies])
|
|
993
|
+
|
|
994
|
+
# Create a second pass baskets removing the entities that were already retrieved
|
|
995
|
+
# we just update the companies list
|
|
996
|
+
second_pass_baskets = []
|
|
997
|
+
for basket_info in baskets_to_search.copy():
|
|
998
|
+
companies_to_search = [
|
|
999
|
+
c
|
|
1000
|
+
for c in basket_info.get("query", {})
|
|
1001
|
+
.get("filters", {})
|
|
1002
|
+
.get("entity", {})
|
|
1003
|
+
.get("any_of", [])
|
|
1004
|
+
if c not in retrieved_entities_cleaned
|
|
1005
|
+
]
|
|
1006
|
+
if companies_to_search:
|
|
1007
|
+
basket_info["query"]["filters"]["entity"]["any_of"] = companies_to_search
|
|
1008
|
+
second_pass_baskets.append(basket_info)
|
|
1009
|
+
|
|
1010
|
+
if second_pass_baskets:
|
|
1011
|
+
logger.info(
|
|
1012
|
+
f"Second pass: searching {len(second_pass_baskets)} baskets with remaining entities"
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1016
|
+
futures = {executor.submit(search_basket, bi): bi for bi in second_pass_baskets}
|
|
1017
|
+
|
|
1018
|
+
completed_2 = 0
|
|
1019
|
+
for future in as_completed(futures):
|
|
1020
|
+
completed_2 += 1
|
|
1021
|
+
bi = futures[future]
|
|
1022
|
+
try:
|
|
1023
|
+
documents, basket_id = future.result()
|
|
1024
|
+
if documents:
|
|
1025
|
+
all_documents.extend(documents)
|
|
1026
|
+
else:
|
|
1027
|
+
failed_baskets.append(basket_id)
|
|
1028
|
+
except Exception as e:
|
|
1029
|
+
logger.error(f"Error processing basket {bi['basket_id']}: {e}")
|
|
1030
|
+
failed_baskets.append(bi["basket_id"])
|
|
1031
|
+
|
|
1032
|
+
if completed_2 % 10 == 0:
|
|
1033
|
+
logger.info(
|
|
1034
|
+
f"Second pass progress: {completed_2}/{len(second_pass_baskets)} baskets completed"
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
second_pass_chunk_count = (
|
|
1038
|
+
sum(len(doc.get("chunks", [])) for doc in all_documents) - first_pass_chunks
|
|
1039
|
+
)
|
|
1040
|
+
logger.info(f"Second pass: retrieved {second_pass_chunk_count} additional chunks")
|
|
1041
|
+
else:
|
|
1042
|
+
logger.info(
|
|
1043
|
+
"Second pass: all entities were found in first pass, no additional search needed"
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
elapsed = time.time() - start_time
|
|
1047
|
+
|
|
1048
|
+
total_chunks = sum(len(doc.get("chunks", [])) for doc in all_documents)
|
|
1049
|
+
|
|
1050
|
+
logger.info(
|
|
1051
|
+
f"Search complete: {len(all_documents)} documents with {total_chunks} chunks retrieved in {elapsed:.2f}s"
|
|
1052
|
+
)
|
|
1053
|
+
if failed_baskets:
|
|
1054
|
+
logger.warning(f"Failed baskets: {len(failed_baskets)}")
|
|
1055
|
+
|
|
1056
|
+
return all_documents
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def save_plan(plan: dict, file_path: str) -> None:
|
|
1060
|
+
"""Save search plan to JSON file."""
|
|
1061
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
1062
|
+
json.dump(plan, f, indent=2)
|
|
1063
|
+
logger.info(f"Plan saved to {file_path}")
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def load_plan(file_path: str) -> dict:
|
|
1067
|
+
"""Load search plan from JSON file."""
|
|
1068
|
+
with open(file_path, encoding="utf-8") as f:
|
|
1069
|
+
plan = json.load(f)
|
|
1070
|
+
logger.info(f"Plan loaded from {file_path}")
|
|
1071
|
+
return plan
|