iatoolkit 1.7.0__py3-none-any.whl → 1.15.3__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.
Files changed (41) hide show
  1. iatoolkit/__init__.py +1 -1
  2. iatoolkit/common/routes.py +16 -3
  3. iatoolkit/common/util.py +8 -123
  4. iatoolkit/core.py +1 -0
  5. iatoolkit/infra/connectors/file_connector.py +10 -2
  6. iatoolkit/infra/connectors/google_drive_connector.py +3 -0
  7. iatoolkit/infra/connectors/local_file_connector.py +3 -0
  8. iatoolkit/infra/connectors/s3_connector.py +24 -1
  9. iatoolkit/infra/llm_providers/deepseek_adapter.py +17 -1
  10. iatoolkit/infra/llm_providers/gemini_adapter.py +117 -18
  11. iatoolkit/infra/llm_providers/openai_adapter.py +175 -18
  12. iatoolkit/infra/llm_response.py +13 -0
  13. iatoolkit/locales/en.yaml +82 -4
  14. iatoolkit/locales/es.yaml +79 -4
  15. iatoolkit/repositories/llm_query_repo.py +51 -18
  16. iatoolkit/repositories/models.py +16 -7
  17. iatoolkit/services/company_context_service.py +294 -133
  18. iatoolkit/services/configuration_service.py +140 -121
  19. iatoolkit/services/dispatcher_service.py +1 -4
  20. iatoolkit/services/knowledge_base_service.py +26 -4
  21. iatoolkit/services/llm_client_service.py +58 -2
  22. iatoolkit/services/prompt_service.py +251 -164
  23. iatoolkit/services/query_service.py +37 -18
  24. iatoolkit/services/storage_service.py +92 -0
  25. iatoolkit/static/js/chat_filepond.js +188 -63
  26. iatoolkit/static/js/chat_main.js +105 -52
  27. iatoolkit/static/styles/chat_iatoolkit.css +96 -0
  28. iatoolkit/system_prompts/query_main.prompt +24 -41
  29. iatoolkit/templates/chat.html +15 -6
  30. iatoolkit/views/base_login_view.py +1 -1
  31. iatoolkit/views/categories_api_view.py +111 -0
  32. iatoolkit/views/chat_view.py +1 -1
  33. iatoolkit/views/configuration_api_view.py +1 -1
  34. iatoolkit/views/login_view.py +1 -1
  35. iatoolkit/views/prompt_api_view.py +88 -7
  36. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/METADATA +1 -1
  37. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/RECORD +41 -39
  38. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/WHEEL +0 -0
  39. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE +0 -0
  40. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
  41. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/top_level.txt +0 -0
@@ -38,41 +38,11 @@ class ConfigurationService:
38
38
  if company_short_name not in self._loaded_configs:
39
39
  self._loaded_configs[company_short_name] = self._load_and_merge_configs(company_short_name)
40
40
 
41
- def get_configuration(self, company_short_name: str, content_key: str):
42
- """
43
- Public method to provide a specific section of a company's configuration.
44
- It uses a cache to avoid reading files from disk on every call.
45
- """
46
- self._ensure_config_loaded(company_short_name)
47
- return self._loaded_configs[company_short_name].get(content_key)
48
-
49
- def get_llm_configuration(self, company_short_name: str):
50
- """
51
- Convenience helper to obtain the 'llm' configuration block for a company.
52
- Kept separate from get_configuration() to avoid coupling tests that
53
- assert the number of calls to get_configuration().
54
- """
55
- default_llm_model = None
56
- available_llm_models = []
57
- self._ensure_config_loaded(company_short_name)
58
- llm_config = self._loaded_configs[company_short_name].get("llm")
59
- if llm_config:
60
- default_llm_model = llm_config.get("model")
61
- available_llm_models = llm_config.get('available_models') or []
62
-
63
- # fallback: if no explicit list of models is provided, use the default model
64
- if not available_llm_models and default_llm_model:
65
- available_llm_models = [{
66
- "id": default_llm_model,
67
- "label": default_llm_model,
68
- "description": "Modelo por defecto configurado para esta compañía."
69
- }]
70
- return default_llm_model, available_llm_models
71
-
72
41
  def load_configuration(self, company_short_name: str):
73
42
  """
74
43
  Main entry point for configuring a company instance.
75
44
  This method is invoked by the dispatcher for each registered company.
45
+ And for the configurator, for editing the configuration of a company.
76
46
  """
77
47
  logging.info(f"⚙️ Starting configuration for company '{company_short_name}'...")
78
48
 
@@ -97,6 +67,14 @@ class ConfigurationService:
97
67
  logging.info(f"✅ Company '{company_short_name}' configured successfully.")
98
68
  return config, errors
99
69
 
70
+ def get_configuration(self, company_short_name: str, content_key: str):
71
+ """
72
+ Public method to provide a specific section of a company's configuration.
73
+ It uses a cache to avoid reading files from disk on every call.
74
+ """
75
+ self._ensure_config_loaded(company_short_name)
76
+ return self._loaded_configs[company_short_name].get(content_key)
77
+
100
78
  def update_configuration_key(self, company_short_name: str, key: str, value) -> tuple[dict, list[str]]:
101
79
  """
102
80
  Updates a specific key in the company's configuration file, validates the result,
@@ -186,7 +164,6 @@ class ConfigurationService:
186
164
 
187
165
  return config, []
188
166
 
189
-
190
167
  def validate_configuration(self, company_short_name: str) -> list[str]:
191
168
  """
192
169
  Public method to trigger validation of the current configuration.
@@ -194,83 +171,6 @@ class ConfigurationService:
194
171
  config = self._load_and_merge_configs(company_short_name)
195
172
  return self._validate_configuration(company_short_name, config)
196
173
 
197
- def _set_nested_value(self, data: dict, key: str, value):
198
- """
199
- Helper to set a value in a nested dictionary or list using dot notation (e.g. 'llm.model', 'tools.0.name').
200
- Handles traversal through both dictionaries and lists.
201
- """
202
- keys = key.split('.')
203
- current = data
204
-
205
- # Traverse up to the parent of the target key
206
- for i, k in enumerate(keys[:-1]):
207
- if isinstance(current, dict):
208
- # If it's a dict, we can traverse or create the path
209
- current = current.setdefault(k, {})
210
- elif isinstance(current, list):
211
- # If it's a list, we MUST use an integer index
212
- try:
213
- idx = int(k)
214
- current = current[idx]
215
- except (ValueError, IndexError) as e:
216
- raise ValueError(
217
- f"Invalid path: cannot access index '{k}' in list at '{'.'.join(keys[:i + 1])}'") from e
218
- else:
219
- raise ValueError(
220
- f"Invalid path: '{k}' is not a container (got {type(current)}) at '{'.'.join(keys[:i + 1])}'")
221
-
222
- # Set the final value
223
- last_key = keys[-1]
224
- if isinstance(current, dict):
225
- current[last_key] = value
226
- elif isinstance(current, list):
227
- try:
228
- idx = int(last_key)
229
- current[idx] = value
230
- except (ValueError, IndexError) as e:
231
- raise ValueError(f"Invalid path: cannot assign to index '{last_key}' in list") from e
232
- else:
233
- raise ValueError(f"Cannot assign value to non-container type {type(current)} at '{key}'")
234
-
235
- def _load_and_merge_configs(self, company_short_name: str) -> dict:
236
- """
237
- Loads the main company.yaml and merges data from supplementary files
238
- specified in the 'content_files' section using AssetRepository.
239
- """
240
- main_config_filename = "company.yaml"
241
-
242
- # verify existence of the main configuration file
243
- if not self.asset_repo.exists(company_short_name, AssetType.CONFIG, main_config_filename):
244
- # raise FileNotFoundError(f"Main configuration file not found: {main_config_filename}")
245
- logging.exception(f"Main configuration file not found: {main_config_filename}")
246
-
247
- # return the minimal configuration needed for starting the IAToolkit
248
- # this is a for solving a chicken/egg problem when trying to migrate the configuration
249
- # from filesystem to database in enterprise installation
250
- # see create_assets cli command in enterprise-iatoolkit)
251
- return {
252
- 'id': company_short_name,
253
- 'name': company_short_name,
254
- 'llm': {'model': 'gpt-5', 'provider_api_keys': {'openai':''} },
255
- }
256
-
257
- # read text and parse
258
- yaml_content = self.asset_repo.read_text(company_short_name, AssetType.CONFIG, main_config_filename)
259
- config = self.utility.load_yaml_from_string(yaml_content)
260
- if not config:
261
- return {}
262
-
263
- # Load and merge supplementary content files (e.g., onboarding_cards)
264
- for key, filename in config.get('help_files', {}).items():
265
- if self.asset_repo.exists(company_short_name, AssetType.CONFIG, filename):
266
- supp_content = self.asset_repo.read_text(company_short_name, AssetType.CONFIG, filename)
267
- config[key] = self.utility.load_yaml_from_string(supp_content)
268
- else:
269
- logging.warning(f"⚠️ Warning: Content file not found: {filename}")
270
- config[key] = None
271
-
272
- return config
273
-
274
174
  def _register_company_database(self, config: dict) -> Company:
275
175
  # register the company in the database: create_or_update logic
276
176
  if not config:
@@ -374,12 +274,11 @@ class ConfigurationService:
374
274
  from iatoolkit.services.prompt_service import PromptService
375
275
  prompt_service = current_iatoolkit().get_injector().get(PromptService)
376
276
 
377
- prompts_config = config.get('prompts', {})
378
-
277
+ prompt_list, categories_config = self._get_prompt_config(config)
379
278
  prompt_service.sync_company_prompts(
380
279
  company_short_name=company_short_name,
381
- prompts_config=prompts_config.get('prompt_list', []),
382
- categories_config=prompts_config.get('prompt_categories', []),
280
+ prompt_list=prompt_list,
281
+ categories_config=categories_config,
383
282
  )
384
283
 
385
284
  def _register_knowledge_base(self, company_short_name: str, config: dict):
@@ -394,7 +293,6 @@ class ConfigurationService:
394
293
  # sync collection types in database
395
294
  knowledge_base.sync_collection_types(company_short_name, categories_config)
396
295
 
397
-
398
296
  def _validate_configuration(self, company_short_name: str, config: dict):
399
297
  """
400
298
  Validates the structure and consistency of the company.yaml configuration.
@@ -462,10 +360,9 @@ class ConfigurationService:
462
360
  add_error(f"tools[{i}]", "'params' key must be a dictionary.")
463
361
 
464
362
  # 6. Prompts
465
- prompt_list = config.get("prompts", {}).get("prompt_list", [])
466
- prompt_categories = config.get("prompts", {}).get("prompt_categories", [])
363
+ prompt_list, categories_config = self._get_prompt_config(config)
467
364
 
468
- category_set = set(prompt_categories)
365
+ category_set = set(categories_config)
469
366
  for i, prompt in enumerate(prompt_list):
470
367
  prompt_name = prompt.get("name")
471
368
  if not prompt_name:
@@ -480,10 +377,12 @@ class ConfigurationService:
480
377
  add_error(f"prompts[{i}]", "Missing required key: 'description'")
481
378
 
482
379
  prompt_cat = prompt.get("category")
483
- if not prompt_cat:
484
- add_error(f"prompts[{i}]", "Missing required key: 'category'")
485
- elif prompt_cat not in category_set:
486
- add_error(f"prompts[{i}]", f"Category '{prompt_cat}' is not defined in 'prompt_categories'.")
380
+ prompt_type = prompt.get("prompt_type", 'company').lower()
381
+ if prompt_type == 'company':
382
+ if not prompt_cat:
383
+ add_error(f"prompts[{i}]", "Missing required key: 'category'")
384
+ elif prompt_cat not in category_set:
385
+ add_error(f"prompts[{i}]", f"Category '{prompt_cat}' is not defined in 'prompt_categories'.")
487
386
 
488
387
  # 7. User Feedback
489
388
  feedback_config = config.get("parameters", {}).get("user_feedback", {})
@@ -530,3 +429,123 @@ class ConfigurationService:
530
429
 
531
430
  return errors
532
431
 
432
+
433
+ def _set_nested_value(self, data: dict, key: str, value):
434
+ """
435
+ Helper to set a value in a nested dictionary or list using dot notation (e.g. 'llm.model', 'tools.0.name').
436
+ Handles traversal through both dictionaries and lists.
437
+ """
438
+ keys = key.split('.')
439
+ current = data
440
+
441
+ # Traverse up to the parent of the target key
442
+ for i, k in enumerate(keys[:-1]):
443
+ if isinstance(current, dict):
444
+ # If it's a dict, we can traverse or create the path
445
+ current = current.setdefault(k, {})
446
+ elif isinstance(current, list):
447
+ # If it's a list, we MUST use an integer index
448
+ try:
449
+ idx = int(k)
450
+ # Allow accessing existing index
451
+ current = current[idx]
452
+ except (ValueError, IndexError) as e:
453
+ raise ValueError(
454
+ f"Invalid path: cannot access index '{k}' in list at '{'.'.join(keys[:i + 1])}'") from e
455
+ else:
456
+ raise ValueError(
457
+ f"Invalid path: '{k}' is not a container (got {type(current)}) at '{'.'.join(keys[:i + 1])}'")
458
+
459
+ # Set the final value
460
+ last_key = keys[-1]
461
+ if isinstance(current, dict):
462
+ current[last_key] = value
463
+ elif isinstance(current, list):
464
+ try:
465
+ idx = int(last_key)
466
+ # If index equals length, it means append
467
+ if idx == len(current):
468
+ current.append(value)
469
+ elif 0 <= idx < len(current):
470
+ current[idx] = value
471
+ else:
472
+ raise IndexError(f"Index {idx} out of range for list of size {len(current)}")
473
+ except (ValueError, IndexError) as e:
474
+ raise ValueError(f"Invalid path: cannot assign to index '{last_key}' in list") from e
475
+ else:
476
+ raise ValueError(f"Cannot assign value to non-container type {type(current)} at '{key}'")
477
+
478
+ def get_llm_configuration(self, company_short_name: str):
479
+ """
480
+ Convenience helper to obtain the 'llm' configuration block for a company.
481
+ Kept separate from get_configuration() to avoid coupling tests that
482
+ assert the number of calls to get_configuration().
483
+ """
484
+ default_llm_model = None
485
+ available_llm_models = []
486
+ self._ensure_config_loaded(company_short_name)
487
+ llm_config = self._loaded_configs[company_short_name].get("llm")
488
+ if llm_config:
489
+ default_llm_model = llm_config.get("model")
490
+ available_llm_models = llm_config.get('available_models') or []
491
+
492
+ # fallback: if no explicit list of models is provided, use the default model
493
+ if not available_llm_models and default_llm_model:
494
+ available_llm_models = [{
495
+ "id": default_llm_model,
496
+ "label": default_llm_model,
497
+ "description": "Modelo por defecto configurado para esta compañía."
498
+ }]
499
+ return default_llm_model, available_llm_models
500
+
501
+
502
+ def _load_and_merge_configs(self, company_short_name: str) -> dict:
503
+ """
504
+ Loads the main company.yaml and merges data from supplementary files
505
+ specified in the 'content_files' section using AssetRepository.
506
+ """
507
+ main_config_filename = "company.yaml"
508
+
509
+ # verify existence of the main configuration file
510
+ if not self.asset_repo.exists(company_short_name, AssetType.CONFIG, main_config_filename):
511
+ # raise FileNotFoundError(f"Main configuration file not found: {main_config_filename}")
512
+ logging.exception(f"Main configuration file not found: {main_config_filename}")
513
+
514
+ # return the minimal configuration needed for starting the IAToolkit
515
+ # this is a for solving a chicken/egg problem when trying to migrate the configuration
516
+ # from filesystem to database in enterprise installation
517
+ # see create_assets cli command in enterprise-iatoolkit)
518
+ return {
519
+ 'id': company_short_name,
520
+ 'name': company_short_name,
521
+ 'llm': {'model': 'gpt-5', 'provider_api_keys': {'openai':''} },
522
+ }
523
+
524
+ # read text and parse
525
+ yaml_content = self.asset_repo.read_text(company_short_name, AssetType.CONFIG, main_config_filename)
526
+ config = self.utility.load_yaml_from_string(yaml_content)
527
+ if not config:
528
+ return {}
529
+
530
+ # Load and merge supplementary content files (e.g., onboarding_cards)
531
+ for key, filename in config.get('help_files', {}).items():
532
+ if self.asset_repo.exists(company_short_name, AssetType.CONFIG, filename):
533
+ supp_content = self.asset_repo.read_text(company_short_name, AssetType.CONFIG, filename)
534
+ config[key] = self.utility.load_yaml_from_string(supp_content)
535
+ else:
536
+ logging.warning(f"⚠️ Warning: Content file not found: {filename}")
537
+ config[key] = None
538
+
539
+ return config
540
+
541
+ def _get_prompt_config(self, config):
542
+ prompts_config = config.get('prompts', {})
543
+ if isinstance(prompts_config, dict):
544
+ prompt_list = prompts_config.get('prompt_list', [])
545
+ categories_config = prompts_config.get('prompt_categories', [])
546
+ else:
547
+ prompt_list = config.get('prompts', [])
548
+ categories_config = config.get('prompt_categories', [])
549
+
550
+ return prompt_list, categories_config
551
+
@@ -87,9 +87,6 @@ class Dispatcher:
87
87
  # system tools registration
88
88
  self.tool_service.register_system_tools()
89
89
 
90
- # system prompts registration
91
- self.prompt_service.register_system_prompts()
92
-
93
90
  except Exception as e:
94
91
  self.llmquery_repo.rollback()
95
92
  raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR, str(e))
@@ -109,7 +106,7 @@ class Dispatcher:
109
106
  if self.tool_service.is_system_tool(function_name):
110
107
  # this is the system function to be executed.
111
108
  handler = self.tool_service.get_system_handler(function_name)
112
- logging.info(
109
+ logging.debug(
113
110
  f"Calling system handler [{function_name}] "
114
111
  f"with company_short_name={company_short_name} "
115
112
  f"and kwargs={kwargs}"
@@ -382,27 +382,49 @@ class KnowledgeBaseService:
382
382
  def sync_collection_types(self, company_short_name: str, categories_config: list):
383
383
  """
384
384
  This should be called during company initialization or configuration reload.
385
+ Syncs DB collection types with the provided list.
386
+ Also updates the configuration YAML.
385
387
  """
386
388
  company = self.profile_service.get_company_by_short_name(company_short_name)
387
389
  if not company:
388
390
  raise IAToolkitException(IAToolkitException.ErrorType.INVALID_NAME,
389
- f'Company {company_short_name} not found')
390
-
391
+ f'Company {company_short_name} not found')
391
392
 
392
393
  session = self.document_repo.session
394
+
395
+ # 1. Get existing types
393
396
  existing_types = session.query(CollectionType).filter_by(company_id=company.id).all()
394
397
  existing_names = {ct.name: ct for ct in existing_types}
395
398
 
399
+ # 2. Add new types
400
+ current_config_names = set()
396
401
  for cat_name in categories_config:
402
+ current_config_names.add(cat_name)
397
403
  if cat_name not in existing_names:
398
404
  new_type = CollectionType(company_id=company.id, name=cat_name)
399
405
  session.add(new_type)
400
406
 
401
- # Opcional: Eliminar los que ya no están en el config?
402
- # Por seguridad de datos, mejor no borrar automáticamente, o marcarlos inactivos.
407
+ # 3. Delete types not in config
408
+ # Note: This might cascade delete documents depending on FK setup.
409
+ # Assuming safe deletion is desired here to match "Sync" behavior.
410
+ for existing_ct in existing_types:
411
+ if existing_ct.name not in current_config_names:
412
+ session.delete(existing_ct)
403
413
 
404
414
  session.commit()
405
415
 
416
+ # 4. Update Configuration YAML
417
+ # Lazy import to avoid circular dependency
418
+ from iatoolkit import current_iatoolkit
419
+ from iatoolkit.services.configuration_service import ConfigurationService
420
+ config_service = current_iatoolkit().get_injector().get(ConfigurationService)
421
+
422
+ config_service.update_configuration_key(
423
+ company_short_name,
424
+ "knowledge_base.collections",
425
+ categories_config
426
+ )
427
+
406
428
  def get_collection_names(self, company_short_name: str) -> List[str]:
407
429
  """
408
430
  Retrieves the names of all collections defined for a specific company.
@@ -21,6 +21,7 @@ import re
21
21
  import tiktoken
22
22
  from typing import Dict, Optional, List
23
23
  from iatoolkit.services.dispatcher_service import Dispatcher
24
+ from iatoolkit.services.storage_service import StorageService
24
25
 
25
26
  CONTEXT_ERROR_MESSAGE = 'Tu consulta supera el límite de contexto, utiliza el boton de recarga de contexto.'
26
27
 
@@ -33,11 +34,13 @@ class llmClient:
33
34
  llmquery_repo: LLMQueryRepo,
34
35
  llm_proxy: LLMProxy,
35
36
  model_registry: ModelRegistry,
37
+ storage_service: StorageService,
36
38
  util: Utility
37
39
  ):
38
40
  self.llmquery_repo = llmquery_repo
39
41
  self.llm_proxy = llm_proxy
40
42
  self.model_registry = model_registry
43
+ self.storage_service = storage_service
41
44
  self.util = util
42
45
  self._dispatcher = None # Cache for the lazy-loaded dispatcher
43
46
 
@@ -69,8 +72,10 @@ class llmClient:
69
72
  text: dict,
70
73
  model: str,
71
74
  context_history: Optional[List[Dict]] = None,
75
+ images: list = None,
72
76
  ) -> dict:
73
77
 
78
+ images = images or []
74
79
  f_calls = [] # keep track of the function calls executed by the LLM
75
80
  f_call_time = 0
76
81
  response = None
@@ -84,7 +89,7 @@ class llmClient:
84
89
 
85
90
  try:
86
91
  start_time = time.time()
87
- logging.info(f"calling llm model '{model}' with {self.count_tokens(context, context_history)} tokens...")
92
+ logging.info(f"calling llm model '{model}' with {self.count_tokens(context, context_history)} tokens...and {len(images)} images...")
88
93
 
89
94
  # this is the first call to the LLM on the iteration
90
95
  try:
@@ -102,6 +107,7 @@ class llmClient:
102
107
  tools=tools,
103
108
  text=text_payload,
104
109
  reasoning=reasoning,
110
+ images=images,
105
111
  )
106
112
  stats = self.get_stats(response)
107
113
 
@@ -163,7 +169,7 @@ class llmClient:
163
169
  error_message = f"Dispatch error en {function_name} con args {args} -******- {str(e)}"
164
170
  raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
165
171
 
166
- # add the return value into the list of messages
172
+ # add the return value into the list of messages
167
173
  input_messages.append({
168
174
  "type": "function_call_output",
169
175
  "call_id": tool_call.call_id,
@@ -198,9 +204,14 @@ class llmClient:
198
204
  tool_choice=tool_choice_value,
199
205
  tools=tools,
200
206
  text=text_payload,
207
+ images=images,
201
208
  )
202
209
  stats_fcall = self.add_stats(stats_fcall, self.get_stats(response))
203
210
 
211
+ # --- IMAGE PROCESSING ---
212
+ # before save or respond, upload the images to S3 and clean content_parts
213
+ self._process_generated_images(response, company.short_name)
214
+
204
215
  # save the statistices
205
216
  stats['response_time']=int(time.time() - start_time)
206
217
  stats['sql_retry_count'] = sql_retry_count
@@ -239,6 +250,7 @@ class llmClient:
239
250
  'query_id': query.id,
240
251
  'model': model,
241
252
  'reasoning_content': final_reasoning,
253
+ 'content_parts': response.content_parts
242
254
  }
243
255
  except SQLAlchemyError as db_error:
244
256
  # rollback
@@ -295,6 +307,50 @@ class llmClient:
295
307
 
296
308
  return response.id
297
309
 
310
+ def _process_generated_images(self, response, company_short_name: str):
311
+ """
312
+ Traverse content_parts, detect images in Base64, upload to S3 and update content_parts.
313
+ """
314
+ if not response.content_parts:
315
+ return
316
+
317
+ for part in response.content_parts:
318
+ if part.get('type') == 'image':
319
+ source = part.get('source', {})
320
+ if source.get('type') in ['base64', 'url']:
321
+ try:
322
+ if source.get('type') == 'url':
323
+ url = source.get('url')
324
+ storage_key = None
325
+ else:
326
+ # upload image to S3
327
+ result = self.storage_service.store_generated_image(
328
+ company_short_name,
329
+ source.get('data'),
330
+ source.get('media_type', 'image/png')
331
+ )
332
+ url = result['url']
333
+ storage_key = result['storage_key']
334
+
335
+ # Update content_part: Now it's a remote reference, not base64 anymore.
336
+ # We keep 'url' for the frontend to display it itself, and storage_key for internal reference.
337
+ part['source'] = {
338
+ 'type': 'url',
339
+ 'url': url,
340
+ 'storage_key': storage_key,
341
+ 'media_type': source.get('media_type')
342
+ }
343
+
344
+ # clean data
345
+ logging.info(f"Imagen procesada y subida: {url}")
346
+
347
+ except Exception as e:
348
+ logging.error(f"Fallo al subir imagen generada: {e}")
349
+
350
+ # Fallback: keep the base64 and signal the error
351
+ part['error'] = "Failed to upload image"
352
+
353
+
298
354
  def decode_response(self, response) -> dict:
299
355
  message = response.output_text
300
356
  decoded_response = {