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.
@@ -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