AbstractIntegratedModule 0.9.7__tar.gz → 0.9.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/PKG-INFO +2 -2
  2. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.py +304 -71
  3. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractOptimizedModules.c +200 -200
  4. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/PKG-INFO +2 -2
  5. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/README.md +1 -1
  6. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/setup.py +1 -1
  7. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/SOURCES.txt +0 -0
  8. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/dependency_links.txt +0 -0
  9. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/requires.txt +0 -0
  10. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractIntegratedModule.egg-info/top_level.txt +0 -0
  11. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/AbstractOptimizedModules.pyx +0 -0
  12. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/MANIFEST.in +0 -0
  13. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/Cargo.toml +0 -0
  14. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/pyproject.toml +0 -0
  15. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/src/lib.rs +0 -0
  16. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/libsqlite3-sys-ed07b882cd2aa5e2/out/bindgen.rs +0 -0
  17. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/serde_core-ebc15f2e9cad7f5f/out/private.rs +0 -0
  18. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/debug/build/target-lexicon-08527f45de28143d/out/host.rs +0 -0
  19. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/libsqlite3-sys-bf0400df4523274c/out/bindgen.rs +0 -0
  20. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/serde_core-5cdb76131825e4af/out/private.rs +0 -0
  21. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/abstract_model_storage/target/release/build/target-lexicon-43eb95a0588bf457/out/host.rs +0 -0
  22. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/pyproject.toml +0 -0
  23. {abstractintegratedmodule-0.9.7 → abstractintegratedmodule-0.9.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AbstractIntegratedModule
3
- Version: 0.9.7
3
+ Version: 0.9.8
4
4
  Summary: Library for Advanced Integrated Non-LLM AI Models - Optimized Backend Framework For Non-LLM AI Agent
5
5
  Author: Micro-Novelty
6
6
  Author-email: hernikpuspita5@gmail.com
@@ -42,7 +42,7 @@ https://github.com/Micro-Novelty/IntegratedPipeline-Specialized-Non-LLM-AI-Agent
42
42
  #### Note: The README here you are reading is a direct copy from my README Repository, to download the necessary files, you can visit my Repository with the provided link above.
43
43
 
44
44
  ### Library Short Description:
45
- - Development Stage: 0.9.7 Official Release.
45
+ - Development Stage: 0.9.8 Official Release.
46
46
  - Maintainer: Micro-Novelty.
47
47
  - library Source-Code is Open-sourced on github.
48
48
  - Purpose: Specifically Designed for providing Non-LLM AI Agent Framework for edge Devices, Optimized for ARM64 architecture.
@@ -3484,11 +3484,15 @@ class WeightedEnsemblePredictor:
3484
3484
  def _meta_ensemble(self, trans_probs, mlp_probs, attn_weights, X_mlp,
3485
3485
  lstm_probs=None, lstm_weight_hint=0.0):
3486
3486
  lstm_row = None
3487
+
3488
+ if trans_probs.ndim == 1: trans_probs = trans_probs[np.newaxis, :]
3489
+ if mlp_probs.ndim == 1: mlp_probs = mlp_probs[np.newaxis, :]
3490
+
3491
+ B = trans_probs.shape[0]
3487
3492
 
3488
3493
  batch_size = trans_probs.shape[0]
3489
- n_classes = trans_probs.shape[1]
3490
3494
  threshold_feature = 0.1 + self.pipeline.confidence_threshold
3491
-
3495
+
3492
3496
  n_trans_classes = trans_probs.shape[1]
3493
3497
  n_mlp_classes = mlp_probs.shape[1]
3494
3498
 
@@ -3504,6 +3508,13 @@ class WeightedEnsemblePredictor:
3504
3508
  n_classes = max(n_trans_classes, n_lstm, n_mlp_classes)
3505
3509
  print(f"🔄 Aligning classes: trans={n_trans_classes} mlp={n_mlp_classes} "
3506
3510
  f"lstm={n_lstm} → {n_classes}")
3511
+
3512
+ if isinstance(lstm_weight_hint, (int, float)):
3513
+ lstm_weight_hints = np.full(B, float(lstm_weight_hint), dtype=np.float64)
3514
+ else:
3515
+ lstm_weight_hints = np.asarray(lstm_weight_hint, dtype=np.float64)
3516
+ if lstm_weight_hints.ndim == 0:
3517
+ lstm_weight_hints = np.full(B, float(lstm_weight_hints))
3507
3518
 
3508
3519
  # Create meta features
3509
3520
  meta_features = []
@@ -3584,7 +3595,7 @@ class WeightedEnsemblePredictor:
3584
3595
  lstm_row = np.zeros(n_classes)
3585
3596
  lstm_row[:n_lstm] = lstm_probs[i]
3586
3597
  lstm_row /= lstm_row.sum() + 1e-8
3587
-
3598
+
3588
3599
  lstm_pred = int(np.argmax(lstm_probs[i]))
3589
3600
  la = 1.0 if (lstm_pred == trans_pred or lstm_pred == mlp_pred) \
3590
3601
  else self.pipeline.confidence_threshold
@@ -12914,117 +12925,272 @@ class AccurateAnswerCache:
12914
12925
 
12915
12926
 
12916
12927
 
12917
-
12918
12928
  class RateLimiter:
12919
- # Token bucket rate limiter
12920
-
12921
- def __init__(self, requests_per_minute: int = 60):
12929
+ """
12930
+ Token bucket rate limiter.
12931
+
12932
+ Supports both:
12933
+ - a single shared bucket (original behavior, backward compatible)
12934
+ - independent per-key buckets (e.g. per peer IP/agent_id),
12935
+ so one noisy peer cannot starve rate-limit capacity from others
12936
+ """
12937
+
12938
+ def __init__(self, requests_per_minute: int = 60, per_key: bool = False,
12939
+ max_keys: int = 1000):
12940
+ #validate config, refuse a limiter that can never refill
12941
+ if requests_per_minute <= 0:
12942
+ raise ValueError(
12943
+ f"[-] requests_per_minute must be > 0, got {requests_per_minute}"
12944
+ )
12945
+
12922
12946
  self.requests_per_minute = requests_per_minute
12923
- self.tokens = requests_per_minute
12924
- self.last_refill = time.time()
12947
+ self.per_key = per_key
12948
+ self.max_keys = max_keys
12925
12949
  self._lock = threading.Lock()
12926
-
12927
- def acquire(self) -> bool:
12950
+
12951
+ if per_key:
12952
+ # one bucket per key, so peers don't share capacity here
12953
+ self._buckets: Dict[str, dict] = {}
12954
+ else:
12955
+ self.tokens = float(requests_per_minute)
12956
+ self.last_refill = time.time()
12957
+
12958
+ def acquire(self, key: str = None) -> bool:
12928
12959
  with self._lock:
12929
- now = time.time()
12930
- # Refill tokens
12931
- elapsed = now - self.last_refill
12932
- new_tokens = elapsed * (self.requests_per_minute / 60)
12933
- self.tokens = min(self.requests_per_minute, self.tokens + new_tokens)
12934
- self.last_refill = now
12935
-
12936
- if self.tokens >= 1:
12937
- self.tokens -= 1
12938
- return True
12939
- return False
12960
+ if self.per_key:
12961
+ return self._acquire_keyed(key or "_default")
12962
+ return self._acquire_global()
12963
+
12964
+ def _acquire_global(self) -> bool:
12965
+ now = time.time()
12966
+ elapsed = now - self.last_refill
12967
+
12968
+ # guarded against clock skew producing negative elapsed
12969
+ if elapsed < 0:
12970
+ logger.warning(
12971
+ f'[!] RateLimiter: system clock moved backward by '
12972
+ f'{-elapsed:.3f}s — ignoring this interval for refill'
12973
+ )
12974
+ elapsed = 0.0
12975
+
12976
+ new_tokens = elapsed * (self.requests_per_minute / 60.0)
12977
+ self.tokens = min(self.requests_per_minute, self.tokens + new_tokens)
12978
+ self.last_refill = now
12979
+
12980
+ if self.tokens >= 1:
12981
+ self.tokens -= 1
12982
+ return True
12983
+ return False
12984
+
12985
+ def _acquire_keyed(self, key: str) -> bool:
12986
+ now = time.time()
12987
+
12988
+ if key not in self._buckets:
12989
+ # cap number of tracked keys to prevent unbounded
12990
+ # growth from an attacker cycling through many fake peer IDs
12991
+ if len(self._buckets) >= self.max_keys:
12992
+ self._evict_oldest_bucket()
12993
+
12994
+ self._buckets[key] = {
12995
+ 'tokens': float(self.requests_per_minute),
12996
+ 'last_refill': now,
12997
+ }
12998
+
12999
+ bucket = self._buckets[key]
13000
+ elapsed = now - bucket['last_refill']
13001
+
13002
+ if elapsed < 0:
13003
+ logger.warning(
13004
+ f'[!] RateLimiter[{key}]: clock moved backward by '
13005
+ f'{-elapsed:.3f}s — ignoring interval'
13006
+ )
13007
+ elapsed = 0.0
13008
+
13009
+ new_tokens = elapsed * (self.requests_per_minute / 60.0)
13010
+ bucket['tokens'] = min(self.requests_per_minute, bucket['tokens'] + new_tokens)
13011
+ bucket['last_refill'] = now
13012
+
13013
+ if bucket['tokens'] >= 1:
13014
+ bucket['tokens'] -= 1
13015
+ return True
13016
+ return False
13017
+
13018
+ def _evict_oldest_bucket(self):
13019
+ """Evict the least-recently-refilled bucket to bound memory use."""
13020
+ if not self._buckets:
13021
+ return
13022
+ oldest_key = min(
13023
+ self._buckets.keys(),
13024
+ key=lambda k: self._buckets[k]['last_refill']
13025
+ )
13026
+ del self._buckets[oldest_key]
13027
+
13028
+ def get_wait_time(self, key: str = None) -> float:
13029
+ """
13030
+ Seconds until at least 1 token will be available.
13031
+ Useful for callers that want to back off.
13032
+ """
13033
+ with self._lock:
13034
+ if self.per_key:
13035
+ bucket = self._buckets.get(key or "_default")
13036
+ tokens = bucket['tokens'] if bucket else self.requests_per_minute
13037
+ else:
13038
+ tokens = self.tokens
13039
+
13040
+ if tokens >= 1:
13041
+ return 0.0
13042
+ tokens_needed = 1 - tokens
13043
+ return tokens_needed / (self.requests_per_minute / 60.0)
13044
+
13045
+ def get_stats(self) -> Dict:
13046
+ """Visibility into limiter state"""
13047
+ with self._lock:
13048
+ if self.per_key:
13049
+ return {
13050
+ 'mode' : 'per_key',
13051
+ 'tracked_keys' : len(self._buckets),
13052
+ 'max_keys' : self.max_keys,
13053
+ 'requests_per_minute': self.requests_per_minute,
13054
+ }
13055
+ return {
13056
+ 'mode' : 'global',
13057
+ 'current_tokens': round(self.tokens, 2),
13058
+ 'requests_per_minute': self.requests_per_minute,
13059
+ }
13060
+
12940
13061
 
12941
13062
  class InputSanitizer:
12942
- # Sanitize and validate inputs
12943
-
13063
+ """Sanitize and validate inputs."""
13064
+
13065
+ # comprehensive control-char stripping including
13066
+ # newline/CR, since this class explicitly exists to protect
13067
+ # logging/serialization from injection
13068
+ _CONTROL_CHARS_PATTERN = re.compile(
13069
+ r'[\x00-\x1f\x7f]'
13070
+ )
13071
+
13072
+ # OPT-IN for genuinely
13073
+ # multi-line legitimate text (e.g. free-form descriptions).
13074
+ _CONTROL_CHARS_ALLOW_WHITESPACE = re.compile(
13075
+ r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'
13076
+ )
13077
+
12944
13078
  @staticmethod
12945
- def sanitize_text(text: str, max_length: int = 10000) -> str:
13079
+ def sanitize_text(text: str, max_length: int = 10000,
13080
+ allow_newlines: bool = False) -> str:
12946
13081
  if not isinstance(text, str):
12947
13082
  raise SecurityError("[-] Input must be a string")
12948
-
12949
- # RemovING null bytes and control characters
12950
- text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
12951
-
12952
- # Limit length
13083
+
13084
+ # check length BEFORE any transformation, so length
13085
+ # limits reflect what was actually submitted.
12953
13086
  if len(text) > max_length:
12954
13087
  raise SecurityError(f"[-] Text exceeds maximum length of {max_length}")
12955
-
12956
- # Remove potential injection patterns (for logging/serialization)
12957
- dangerous_patterns = [
12958
- r'[\x00-\x08\x0b\x0c\x0e-\x1f]', # Control chars
12959
- r'\\x[0-9a-fA-F]{2}', # Hex escapes
12960
- r'\\u[0-9a-fA-F]{4}', # Unicode escapes
13088
+
13089
+ if allow_newlines:
13090
+ text = InputSanitizer._CONTROL_CHARS_ALLOW_WHITESPACE.sub('', text)
13091
+ # even when newlines are allowed, escape them for log safety
13092
+ else:
13093
+ text = InputSanitizer._CONTROL_CHARS_PATTERN.sub('', text)
13094
+
13095
+ escape_patterns = [
13096
+ r'\\x[0-9a-fA-F]{2}', # literal "\x41" style escapes
13097
+ r'\\u[0-9a-fA-F]{4}', # literal "\u0041" style escapes
12961
13098
  ]
12962
-
12963
- for pattern in dangerous_patterns:
13099
+ for pattern in escape_patterns:
12964
13100
  if re.search(pattern, text):
12965
- # Log but don't block - just escape
13101
+ logger.warning(
13102
+ f'[!] sanitize_text: literal escape sequence pattern '
13103
+ f'detected and neutralized (pattern={pattern})'
13104
+ )
12966
13105
  text = re.sub(pattern, '?', text)
12967
-
13106
+
12968
13107
  return text.strip()
12969
-
13108
+
12970
13109
  @staticmethod
12971
13110
  def validate_batch_size(size: int, max_batch: int = 100) -> bool:
13111
+ # validated type before comparison to avoid a confusing
13112
+ # TypeError if a non-int slips through
13113
+ if not isinstance(size, int):
13114
+ raise SecurityError(f"[-] Batch size must be an integer, got {type(size).__name__}")
12972
13115
  if size <= 0 or size > max_batch:
12973
13116
  raise SecurityError(f"[-] Batch size must be between 1 and {max_batch}")
12974
13117
  return True
12975
13118
 
13119
+
12976
13120
  class ApiKeyManager:
12977
- # Manage API keys with rotation
12978
-
12979
- def __init__(self, rotation_days: int = 30):
12980
- self.keys: Dict[str, dict] = {} # key_hash -> {created_at, last_used, metadata}
13121
+ """Manage API keys with rotation and bounded storage."""
13122
+
13123
+ MIN_KEY_LENGTH = 16 # minimum acceptable length for caller-supplied keys
13124
+
13125
+ def __init__(self, rotation_days: int = 30, max_keys: int = 10000):
13126
+ self.keys: Dict[str, dict] = {}
12981
13127
  self.rotation_days = rotation_days
13128
+ self.max_keys = max_keys
12982
13129
  self._lock = threading.Lock()
12983
-
13130
+
12984
13131
  def generate_key(self, metadata: dict = None, key_value: str = None) -> str:
12985
- # Generate a new API key
12986
13132
  if key_value:
12987
- # Use provided key value
13133
+ # reject obviously weak caller-supplied keys
13134
+ if not isinstance(key_value, str) or len(key_value) < self.MIN_KEY_LENGTH:
13135
+ raise SecurityError(
13136
+ f"[-] Provided key must be a string of at least "
13137
+ f"{self.MIN_KEY_LENGTH} characters"
13138
+ )
12988
13139
  raw_key = key_value
12989
13140
  else:
12990
- # Generate random key
12991
- raw_key = secrets.token_urlsafe(32)
13141
+ raw_key = secrets.token_urlsafe(32)
12992
13142
 
12993
13143
  key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
12994
-
13144
+
12995
13145
  with self._lock:
13146
+ # warn on collision
13147
+ if key_hash in self.keys and self.keys[key_hash].get('is_active'):
13148
+ logger.warning(
13149
+ f'[!] generate_key: key hash collision with an '
13150
+ f'already-active key — overwriting metadata'
13151
+ )
13152
+
13153
+ # evict oldest inactive entries if at capacity,
13154
+ if len(self.keys) >= self.max_keys:
13155
+ self._evict_oldest_inactive()
13156
+
12996
13157
  self.keys[key_hash] = {
12997
13158
  'created_at': datetime.now(),
12998
- 'last_used': None,
12999
- 'metadata': metadata or {},
13000
- 'is_active': True
13159
+ 'last_used' : None,
13160
+ 'metadata' : metadata or {},
13161
+ 'is_active' : True
13001
13162
  }
13002
-
13163
+
13003
13164
  return raw_key
13004
-
13165
+
13005
13166
  def validate_key(self, api_key: str) -> bool:
13006
- # Validate an API key
13007
- if not api_key:
13167
+ # explicit type check before .encode(), avoids an
13168
+ # uncontrolled AttributeError on non-string input
13169
+ if not api_key or not isinstance(api_key, str):
13008
13170
  return False
13009
-
13010
- key_hash = hashlib.sha256(api_key.encode()).hexdigest()
13011
-
13171
+
13172
+ try:
13173
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
13174
+ except (UnicodeEncodeError, AttributeError):
13175
+ return False
13176
+
13012
13177
  with self._lock:
13013
13178
  key_info = self.keys.get(key_hash)
13014
13179
  if not key_info or not key_info.get('is_active', False):
13015
13180
  return False
13016
-
13017
- # Check rotation
13181
+
13018
13182
  age = (datetime.now() - key_info['created_at']).days
13019
13183
  if age >= self.rotation_days:
13020
13184
  key_info['is_active'] = False
13021
13185
  return False
13022
-
13186
+
13023
13187
  key_info['last_used'] = datetime.now()
13024
13188
  return True
13025
-
13189
+
13026
13190
  def revoke_key(self, api_key: str) -> bool:
13027
- # Revoke an API key
13191
+ if not api_key or not isinstance(api_key, str):
13192
+ return False
13193
+
13028
13194
  key_hash = hashlib.sha256(api_key.encode()).hexdigest()
13029
13195
  with self._lock:
13030
13196
  if key_hash in self.keys:
@@ -13032,6 +13198,54 @@ class ApiKeyManager:
13032
13198
  return True
13033
13199
  return False
13034
13200
 
13201
+ def cleanup_expired(self, grace_period_days: int = 7):
13202
+ """
13203
+ periodic sweep to actually remove long-inactive keys.
13204
+ """
13205
+ with self._lock:
13206
+ now = datetime.now()
13207
+ to_remove = [
13208
+ key_hash for key_hash, info in self.keys.items()
13209
+ if not info.get('is_active', False)
13210
+ and (now - info['created_at']).days >= (self.rotation_days + grace_period_days)
13211
+ ]
13212
+ for key_hash in to_remove:
13213
+ del self.keys[key_hash]
13214
+
13215
+ if to_remove:
13216
+ logger.info(f'[=] Cleaned up {len(to_remove)} expired API key entries')
13217
+
13218
+ return len(to_remove)
13219
+
13220
+ def _evict_oldest_inactive(self):
13221
+ """Evict the oldest inactive key to make room, called under lock."""
13222
+ inactive = [
13223
+ (h, info) for h, info in self.keys.items()
13224
+ if not info.get('is_active', False)
13225
+ ]
13226
+ if inactive:
13227
+ oldest_hash = min(inactive, key=lambda x: x[1]['created_at'])[0]
13228
+ del self.keys[oldest_hash]
13229
+ else:
13230
+ # no inactive keys to evict and still at capacity
13231
+ logger.warning(
13232
+ f'[!] ApiKeyManager at max_keys={self.max_keys} capacity '
13233
+ f'with no inactive keys to evict — consider raising max_keys '
13234
+ f'or auditing why so many keys remain active'
13235
+ )
13236
+
13237
+ def get_stats(self) -> Dict:
13238
+ """Visibility into key store health — same pattern as WorkerPool.get_health()."""
13239
+ with self._lock:
13240
+ active = sum(1 for k in self.keys.values() if k.get('is_active'))
13241
+ inactive = len(self.keys) - active
13242
+ return {
13243
+ 'total_keys' : len(self.keys),
13244
+ 'active_keys' : active,
13245
+ 'inactive_keys': inactive,
13246
+ 'at_capacity' : len(self.keys) >= self.max_keys,
13247
+ }
13248
+
13035
13249
  class AsyncResultQueue:
13036
13250
  """
13037
13251
  Complete result queue with integrated processor.
@@ -16037,6 +16251,7 @@ class PipelinePredictionManager:
16037
16251
 
16038
16252
  if X is None or len(X) == 0 or isinstance(X, int) or (isinstance(X, np.ndarray) and X.size == 0):
16039
16253
  # Get MLP predictions
16254
+ titles = None
16040
16255
  if titles is not None and len(titles) > 0:
16041
16256
  if isinstance(titles[0], tuple):
16042
16257
  mlp_titles = [t[0] for t in titles]
@@ -16045,8 +16260,16 @@ class PipelinePredictionManager:
16045
16260
 
16046
16261
  if not hasattr(self.pipeline, 'tfidf') or self.pipeline.tfidf is None:
16047
16262
  self.pipeline.initialize_fitting(mlp_titles)
16048
-
16049
- X = self.pipeline.tfidf.transform(mlp_titles).toarray()
16263
+
16264
+ if isinstance(mlp_titles, (list, tuple, np.ndarray)):
16265
+ titles = mlp_titles[0]
16266
+ if isinstance(mlp_titles, (list, tuple, np.ndarray)):
16267
+ titles = titles[0]
16268
+
16269
+ if not isinstance(mlp_titles, list) or isinstance(titles, str):
16270
+ if titles is not None:
16271
+ mlp_titles = titles
16272
+ X = self.pipeline.tfidf.transform(mlp_titles).toarray()
16050
16273
 
16051
16274
  if X_gen is not None:
16052
16275
  X = X_gen
@@ -16380,9 +16603,19 @@ class PipelinePredictionManager:
16380
16603
  }
16381
16604
  results.append(result)
16382
16605
 
16383
- final_probs = results[0]['final_probs'] if results else None
16384
- final_class_idx = results[0]['predicted_idx'] if results else None
16385
- agreement = results[0]['models_agree'] if results else None
16606
+ if results is not None and isinstance(results[0], dict):
16607
+ try:
16608
+ final_probs = results[0]['final_probs'] if results else None
16609
+ final_class_idx = results[0]['predicted_idx'] if results else None
16610
+ agreement = results[0]['models_agree'] if results else None
16611
+ except:
16612
+ final_probs = results[0].get('final_probs', None)
16613
+ final_class_idx = results[0].get('predicted_idx', None)
16614
+ agreement = results[0].get('models_agree', None)
16615
+ else:
16616
+ final_probs = mlp_probs
16617
+ final_class_idx = target_pred_indices
16618
+ agreement = False
16386
16619
 
16387
16620
  # Display results
16388
16621
  verbose = False