iatoolkit 0.66.2__py3-none-any.whl → 0.71.2__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 (73) hide show
  1. iatoolkit/__init__.py +2 -6
  2. iatoolkit/base_company.py +3 -31
  3. iatoolkit/cli_commands.py +1 -1
  4. iatoolkit/common/routes.py +5 -1
  5. iatoolkit/common/session_manager.py +2 -0
  6. iatoolkit/company_registry.py +1 -2
  7. iatoolkit/iatoolkit.py +13 -13
  8. iatoolkit/infra/llm_client.py +8 -12
  9. iatoolkit/infra/llm_proxy.py +38 -10
  10. iatoolkit/locales/en.yaml +25 -2
  11. iatoolkit/locales/es.yaml +27 -4
  12. iatoolkit/repositories/database_manager.py +8 -3
  13. iatoolkit/repositories/document_repo.py +1 -1
  14. iatoolkit/repositories/models.py +6 -8
  15. iatoolkit/repositories/profile_repo.py +0 -4
  16. iatoolkit/repositories/vs_repo.py +26 -20
  17. iatoolkit/services/auth_service.py +2 -2
  18. iatoolkit/services/branding_service.py +11 -7
  19. iatoolkit/services/company_context_service.py +155 -0
  20. iatoolkit/services/configuration_service.py +133 -0
  21. iatoolkit/services/dispatcher_service.py +75 -70
  22. iatoolkit/services/document_service.py +5 -2
  23. iatoolkit/services/embedding_service.py +146 -0
  24. iatoolkit/services/excel_service.py +15 -11
  25. iatoolkit/services/file_processor_service.py +4 -12
  26. iatoolkit/services/history_service.py +7 -7
  27. iatoolkit/services/i18n_service.py +4 -4
  28. iatoolkit/services/jwt_service.py +7 -9
  29. iatoolkit/services/language_service.py +29 -23
  30. iatoolkit/services/load_documents_service.py +100 -113
  31. iatoolkit/services/mail_service.py +9 -4
  32. iatoolkit/services/profile_service.py +10 -7
  33. iatoolkit/services/prompt_manager_service.py +20 -16
  34. iatoolkit/services/query_service.py +112 -43
  35. iatoolkit/services/search_service.py +11 -4
  36. iatoolkit/services/sql_service.py +57 -25
  37. iatoolkit/services/user_feedback_service.py +15 -13
  38. iatoolkit/static/js/chat_history_button.js +3 -5
  39. iatoolkit/static/js/chat_main.js +2 -17
  40. iatoolkit/static/js/chat_onboarding_button.js +6 -0
  41. iatoolkit/static/styles/chat_iatoolkit.css +69 -158
  42. iatoolkit/static/styles/chat_modal.css +1 -37
  43. iatoolkit/static/styles/onboarding.css +7 -0
  44. iatoolkit/system_prompts/query_main.prompt +2 -10
  45. iatoolkit/templates/change_password.html +1 -1
  46. iatoolkit/templates/chat.html +12 -4
  47. iatoolkit/templates/chat_modals.html +4 -0
  48. iatoolkit/templates/error.html +1 -1
  49. iatoolkit/templates/login_simulation.html +17 -6
  50. iatoolkit/templates/onboarding_shell.html +4 -1
  51. iatoolkit/views/base_login_view.py +7 -8
  52. iatoolkit/views/change_password_view.py +2 -3
  53. iatoolkit/views/embedding_api_view.py +65 -0
  54. iatoolkit/views/external_login_view.py +1 -1
  55. iatoolkit/views/file_store_api_view.py +1 -1
  56. iatoolkit/views/forgot_password_view.py +2 -4
  57. iatoolkit/views/help_content_api_view.py +9 -9
  58. iatoolkit/views/history_api_view.py +1 -1
  59. iatoolkit/views/home_view.py +2 -2
  60. iatoolkit/views/init_context_api_view.py +18 -17
  61. iatoolkit/views/llmquery_api_view.py +3 -2
  62. iatoolkit/views/login_simulation_view.py +14 -2
  63. iatoolkit/views/login_view.py +9 -9
  64. iatoolkit/views/signup_view.py +2 -4
  65. iatoolkit/views/verify_user_view.py +2 -4
  66. {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.2.dist-info}/METADATA +40 -22
  67. iatoolkit-0.71.2.dist-info/RECORD +122 -0
  68. iatoolkit-0.71.2.dist-info/licenses/LICENSE +21 -0
  69. iatoolkit/services/help_content_service.py +0 -30
  70. iatoolkit/services/onboarding_service.py +0 -43
  71. iatoolkit-0.66.2.dist-info/RECORD +0 -119
  72. {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.2.dist-info}/WHEEL +0 -0
  73. {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.2.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,9 @@ from iatoolkit.services.profile_service import ProfileService
8
8
  from iatoolkit.repositories.document_repo import DocumentRepo
9
9
  from iatoolkit.repositories.profile_repo import ProfileRepo
10
10
  from iatoolkit.services.document_service import DocumentService
11
+ from iatoolkit.services.company_context_service import CompanyContextService
12
+ from iatoolkit.services.i18n_service import I18nService
13
+ from iatoolkit.services.configuration_service import ConfigurationService
11
14
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
12
15
  from iatoolkit.repositories.models import Task
13
16
  from iatoolkit.services.dispatcher_service import Dispatcher
@@ -32,31 +35,60 @@ class QueryService:
32
35
  def __init__(self,
33
36
  llm_client: llmClient,
34
37
  profile_service: ProfileService,
38
+ company_context_service: CompanyContextService,
35
39
  document_service: DocumentService,
36
40
  document_repo: DocumentRepo,
37
41
  llmquery_repo: LLMQueryRepo,
38
42
  profile_repo: ProfileRepo,
39
43
  prompt_service: PromptService,
44
+ i18n_service: I18nService,
40
45
  util: Utility,
41
46
  dispatcher: Dispatcher,
42
- session_context: UserSessionContextService
47
+ session_context: UserSessionContextService,
48
+ configuration_service: ConfigurationService
43
49
  ):
44
50
  self.profile_service = profile_service
51
+ self.company_context_service = company_context_service
45
52
  self.document_service = document_service
46
53
  self.document_repo = document_repo
47
54
  self.llmquery_repo = llmquery_repo
48
55
  self.profile_repo = profile_repo
49
56
  self.prompt_service = prompt_service
57
+ self.i18n_service = i18n_service
50
58
  self.util = util
51
59
  self.dispatcher = dispatcher
52
60
  self.session_context = session_context
61
+ self.configuration_service = configuration_service
53
62
  self.llm_client = llm_client
54
63
 
55
64
  # get the model from the environment variable
56
- self.model = os.getenv("LLM_MODEL", "")
57
- if not self.model:
65
+ self.default_model = os.getenv("LLM_MODEL", "")
66
+ if not self.default_model:
58
67
  raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
59
- "La variable de entorno 'LLM_MODEL' no está configurada.")
68
+ "missing ENV variable 'LLM_MODEL' configuration.")
69
+
70
+ def init_context(self, company_short_name: str,
71
+ user_identifier: str,
72
+ model: str = None) -> dict:
73
+
74
+ # 1. Execute the forced rebuild sequence using the unified identifier.
75
+ self.session_context.clear_all_context(company_short_name, user_identifier)
76
+ logging.info(f"Context for {company_short_name}/{user_identifier} has been cleared.")
77
+
78
+ # 2. LLM context is clean, now we can load it again
79
+ self.prepare_context(
80
+ company_short_name=company_short_name,
81
+ user_identifier=user_identifier
82
+ )
83
+
84
+ # 3. communicate the new context to the LLM
85
+ response = self.set_context_for_llm(
86
+ company_short_name=company_short_name,
87
+ user_identifier=user_identifier,
88
+ model=model
89
+ )
90
+
91
+ return response
60
92
 
61
93
  def _build_context_and_profile(self, company_short_name: str, user_identifier: str) -> tuple:
62
94
  # this method read the user/company context from the database and renders the system prompt
@@ -78,7 +110,7 @@ class QueryService:
78
110
  )
79
111
 
80
112
  # get the company context: schemas, database models, .md files
81
- company_specific_context = self.dispatcher.get_company_context(company_name=company_short_name)
113
+ company_specific_context = self.company_context_service.get_company_context(company_short_name)
82
114
 
83
115
  # merge context: company + user
84
116
  final_system_context = f"{company_specific_context}\n{rendered_system_prompt}"
@@ -112,22 +144,39 @@ class QueryService:
112
144
 
113
145
  if rebuild_is_needed:
114
146
  # Guardar el contexto preparado y su versión para que `finalize_context_rebuild` los use.
115
- self.session_context.save_prepared_context(company_short_name, user_identifier, final_system_context,
147
+ self.session_context.save_prepared_context(company_short_name,
148
+ user_identifier,
149
+ final_system_context,
116
150
  current_version)
117
151
 
118
152
  return {'rebuild_needed': rebuild_is_needed}
119
153
 
120
- def finalize_context_rebuild(self, company_short_name: str, user_identifier: str, model: str = ''):
154
+ def set_context_for_llm(self,
155
+ company_short_name: str,
156
+ user_identifier: str,
157
+ model: str = ''):
121
158
 
122
- # end the initilization, if there is a prepare context send it to llm
123
- if not model:
124
- model = self.model
159
+ # This service takes a pre-built context and send to the LLM
160
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
161
+ if not company:
162
+ logging.error(f"Company not found: {company_short_name} in set_context_for_llm")
163
+ return
164
+
165
+ # --- Model Resolution ---
166
+ # Priority: 1. Explicit model -> 2. Company config -> 3. Global default
167
+ effective_model = model
168
+ if not effective_model:
169
+ llm_config = self.configuration_service.get_configuration(company_short_name, 'llm')
170
+ if llm_config and llm_config.get('model'):
171
+ effective_model = llm_config['model']
125
172
 
126
- # --- Lógica de Bloqueo ---
173
+ effective_model = effective_model or self.default_model
174
+
175
+ # blocking logic to avoid multiple requests for the same user/company at the same time
127
176
  lock_key = f"lock:context:{company_short_name}/{user_identifier}"
128
177
  if not self.session_context.acquire_lock(lock_key, expire_seconds=60):
129
178
  logging.warning(
130
- f"Intento de reconstruir contexto para {user_identifier} mientras ya estaba en progreso. Se omite.")
179
+ f"try to rebuild context for user {user_identifier} while is still in process, ignored.")
131
180
  return
132
181
 
133
182
  try:
@@ -138,22 +187,23 @@ class QueryService:
138
187
  prepared_context, version_to_save = self.session_context.get_and_clear_prepared_context(company_short_name,
139
188
  user_identifier)
140
189
  if not prepared_context:
141
- logging.info(
142
- f"No se requiere reconstrucción de contexto para {company_short_name}/{user_identifier}. Finalización rápida.")
143
190
  return
144
191
 
145
- logging.info(f"Enviando contexto al LLM para {company_short_name}/{user_identifier}...")
192
+ logging.info(f"sending context to LLM model {effective_model} for: {company_short_name}/{user_identifier}...")
146
193
 
147
- # Limpiar solo el historial de chat y el ID de respuesta anterior
194
+ # clean only the chat history and the last response ID for this user/company
148
195
  self.session_context.clear_llm_history(company_short_name, user_identifier)
149
196
 
150
- if self.util.is_gemini_model(model):
197
+ response_id = ''
198
+ if self.util.is_gemini_model(effective_model):
151
199
  context_history = [{"role": "user", "content": prepared_context}]
152
200
  self.session_context.save_context_history(company_short_name, user_identifier, context_history)
153
-
154
- elif self.util.is_openai_model(model):
201
+ elif self.util.is_openai_model(effective_model):
202
+ # Here is the call to the LLM client for settling the company/user context
155
203
  response_id = self.llm_client.set_company_context(
156
- company=company, company_base_context=prepared_context, model=model
204
+ company=company,
205
+ company_base_context=prepared_context,
206
+ model=effective_model
157
207
  )
158
208
  self.session_context.save_last_response_id(company_short_name, user_identifier, response_id)
159
209
 
@@ -161,14 +211,16 @@ class QueryService:
161
211
  self.session_context.save_context_version(company_short_name, user_identifier, version_to_save)
162
212
 
163
213
  logging.info(
164
- f"Contexto de {company_short_name}/{user_identifier} establecido en {int(time.time() - start_time)} seg.")
214
+ f"Context for: {company_short_name}/{user_identifier} settled in {int(time.time() - start_time)} sec.")
165
215
  except Exception as e:
166
- logging.exception(f"Error en finalize_context_rebuild para {company_short_name}: {e}")
216
+ logging.exception(f"Error in finalize_context_rebuild for {company_short_name}: {e}")
167
217
  raise e
168
218
  finally:
169
- # --- Liberar el Bloqueo ---
219
+ # release the lock
170
220
  self.session_context.release_lock(lock_key)
171
221
 
222
+ return {'response_id': response_id }
223
+
172
224
  def llm_query(self,
173
225
  company_short_name: str,
174
226
  user_identifier: str,
@@ -176,29 +228,45 @@ class QueryService:
176
228
  prompt_name: str = None,
177
229
  question: str = '',
178
230
  client_data: dict = {},
179
- files: list = []) -> dict:
231
+ response_id: str = '',
232
+ files: list = [],
233
+ model: Optional[str] = None) -> dict:
180
234
  try:
181
235
  company = self.profile_repo.get_company_by_short_name(short_name=company_short_name)
182
236
  if not company:
183
237
  return {"error": True,
184
- "error_message": f'No existe Company ID: {company_short_name}'}
238
+ "error_message": self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
185
239
 
186
240
  if not prompt_name and not question:
187
241
  return {"error": True,
188
- "error_message": f'Hola, cual es tu pregunta?'}
242
+ "error_message": self.i18n_service.t('services.start_query')}
243
+
244
+ # --- Model Resolution ---
245
+ # Priority: 1. Explicit model -> 2. Company config -> 3. Global default
246
+ effective_model = model
247
+ if not effective_model:
248
+ llm_config = self.configuration_service.get_configuration(company_short_name, 'llm')
249
+ if llm_config and llm_config.get('model'):
250
+ effective_model = llm_config['model']
251
+
252
+ effective_model = effective_model or self.default_model
189
253
 
190
254
  # get the previous response_id and context history
191
255
  previous_response_id = None
192
256
  context_history = self.session_context.get_context_history(company.short_name, user_identifier) or []
193
257
 
194
- if self.util.is_openai_model(self.model):
195
- # get user context
196
- previous_response_id = self.session_context.get_last_response_id(company.short_name, user_identifier)
197
- if not previous_response_id:
198
- return {'error': True,
199
- "error_message": f"No se encontró 'previous_response_id' para '{company.short_name}/{user_identifier}'. Reinicia el contexto para continuar."
200
- }
201
- elif self.util.is_gemini_model(self.model):
258
+ if self.util.is_openai_model(effective_model):
259
+ if response_id:
260
+ # context is getting from this response_id
261
+ previous_response_id = response_id
262
+ else:
263
+ # use the full user history context
264
+ previous_response_id = self.session_context.get_last_response_id(company.short_name, user_identifier)
265
+ if not previous_response_id:
266
+ return {'error': True,
267
+ "error_message": self.i18n_service.t('errors.services.missing_response_id', company_short_name=company.short_name, user_identifier=user_identifier)
268
+ }
269
+ elif self.util.is_gemini_model(effective_model):
202
270
  # check the length of the context_history and remove old messages
203
271
  self._trim_context_history(context_history)
204
272
 
@@ -237,7 +305,7 @@ class QueryService:
237
305
  user_turn_prompt += f'\n### Contexto Adicional: El usuario ha aportado este contexto puede ayudar: {question}'
238
306
 
239
307
  # add to the history context
240
- if self.util.is_gemini_model(self.model):
308
+ if self.util.is_gemini_model(effective_model):
241
309
  context_history.append({"role": "user", "content": user_turn_prompt})
242
310
 
243
311
  # service list for the function calls
@@ -250,8 +318,9 @@ class QueryService:
250
318
  response = self.llm_client.invoke(
251
319
  company=company,
252
320
  user_identifier=user_identifier,
321
+ model=effective_model,
253
322
  previous_response_id=previous_response_id,
254
- context_history=context_history if self.util.is_gemini_model(self.model) else None,
323
+ context_history=context_history if self.util.is_gemini_model(effective_model) else None,
255
324
  question=question,
256
325
  context=user_turn_prompt,
257
326
  tools=tools,
@@ -264,7 +333,7 @@ class QueryService:
264
333
  # save last_response_id for the history chain
265
334
  if "response_id" in response:
266
335
  self.session_context.save_last_response_id(company.short_name, user_identifier, response["response_id"])
267
- if self.util.is_gemini_model(self.model):
336
+ if self.util.is_gemini_model(effective_model):
268
337
  self.session_context.save_context_history(company.short_name, user_identifier, context_history)
269
338
 
270
339
  return response
@@ -286,15 +355,15 @@ class QueryService:
286
355
  - Gemini: context_history con al menos 1 mensaje.
287
356
  """
288
357
  try:
289
- if self.util.is_openai_model(self.model):
358
+ if self.util.is_openai_model(self.default_model):
290
359
  prev_id = self.session_context.get_last_response_id(company_short_name, user_identifier)
291
360
  return bool(prev_id)
292
- if self.util.is_gemini_model(self.model):
361
+ if self.util.is_gemini_model(self.default_model):
293
362
  history = self.session_context.get_context_history(company_short_name, user_identifier) or []
294
363
  return len(history) >= 1
295
364
  return False
296
365
  except Exception as e:
297
- logging.warning(f"Error verificando caché de contexto: {e}")
366
+ logging.warning(f"error verifying context cache: {e}")
298
367
  return False
299
368
 
300
369
  def load_files_for_context(self, files: list) -> str:
@@ -353,7 +422,7 @@ class QueryService:
353
422
  try:
354
423
  total_tokens = sum(self.llm_client.count_tokens(json.dumps(message)) for message in context_history)
355
424
  except Exception as e:
356
- logging.error(f"Error al calcular tokens del historial: {e}. No se pudo recortar el contexto.")
425
+ logging.error(f"error counting tokens for history: {e}.")
357
426
  return
358
427
 
359
428
  # Si se excede el límite, eliminar mensajes antiguos (empezando por el segundo)
@@ -364,8 +433,8 @@ class QueryService:
364
433
  removed_tokens = self.llm_client.count_tokens(json.dumps(removed_message))
365
434
  total_tokens -= removed_tokens
366
435
  logging.warning(
367
- f"Historial de contexto ({total_tokens + removed_tokens} tokens) excedía el límite de {GEMINI_MAX_TOKENS_CONTEXT_HISTORY}. "
368
- f"Nuevo total: {total_tokens} tokens."
436
+ f"history tokens ({total_tokens + removed_tokens} tokens) exceed the limit of: {GEMINI_MAX_TOKENS_CONTEXT_HISTORY}. "
437
+ f"new context: {total_tokens} tokens."
369
438
  )
370
439
  except IndexError:
371
440
  # Se produce si solo queda el mensaje del sistema, el bucle debería detenerse.
@@ -5,19 +5,22 @@
5
5
 
6
6
  from iatoolkit.repositories.vs_repo import VSRepo
7
7
  from iatoolkit.repositories.document_repo import DocumentRepo
8
+ from iatoolkit.repositories.profile_repo import ProfileRepo
9
+ from iatoolkit.repositories.models import Company
8
10
  from injector import inject
9
11
 
10
12
 
11
13
  class SearchService:
12
14
  @inject
13
15
  def __init__(self,
16
+ profile_repo: ProfileRepo,
14
17
  doc_repo: DocumentRepo,
15
18
  vs_repo: VSRepo):
16
- super().__init__()
19
+ self.profile_repo = profile_repo
17
20
  self.vs_repo = vs_repo
18
21
  self.doc_repo = doc_repo
19
22
 
20
- def search(self, company_id: int, query: str, metadata_filter: dict = None) -> str:
23
+ def search(self, company_short_name: str, query: str, metadata_filter: dict = None) -> str:
21
24
  """
22
25
  Performs a semantic search for a given query within a company's documents.
23
26
 
@@ -26,7 +29,7 @@ class SearchService:
26
29
  content of the retrieved documents, which can be used as context for an LLM.
27
30
 
28
31
  Args:
29
- company_id: The ID of the company to search within.
32
+ company_short_name: The company to search within.
30
33
  query: The text query to search for.
31
34
  metadata_filter: An optional dictionary to filter documents by their metadata.
32
35
 
@@ -34,7 +37,11 @@ class SearchService:
34
37
  A string containing the concatenated content of the found documents,
35
38
  formatted to be used as a context.
36
39
  """
37
- document_list = self.vs_repo.query(company_id=company_id,
40
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
41
+ if not company:
42
+ return f"error: company {company_short_name} not found"
43
+
44
+ document_list = self.vs_repo.query(company_short_name=company_short_name,
38
45
  query_text=query,
39
46
  metadata_filter=metadata_filter)
40
47
 
@@ -4,57 +4,89 @@
4
4
  # IAToolkit is open source software.
5
5
 
6
6
  from iatoolkit.repositories.database_manager import DatabaseManager
7
-
8
7
  from iatoolkit.common.util import Utility
8
+ from iatoolkit.services.i18n_service import I18nService
9
+ from iatoolkit.common.exceptions import IAToolkitException
9
10
  from sqlalchemy import text
10
- from injector import inject
11
+ from injector import inject, singleton
11
12
  import json
12
- from iatoolkit.common.exceptions import IAToolkitException
13
+ import logging
13
14
 
14
15
 
16
+ @singleton
15
17
  class SqlService:
18
+ """
19
+ Manages database connections and executes SQL statements.
20
+ It maintains a cache of named DatabaseManager instances to avoid reconnecting.
21
+ """
22
+
16
23
  @inject
17
- def __init__(self,util: Utility):
24
+ def __init__(self,
25
+ util: Utility,
26
+ i18n_service: I18nService):
18
27
  self.util = util
28
+ self.i18n_service = i18n_service
29
+
30
+ # Cache for database connections
31
+ self._db_connections: dict[str, DatabaseManager] = {}
19
32
 
20
- def exec_sql(self, db_manager: DatabaseManager, sql_statement: str) -> str:
33
+ def register_database(self, db_name: str, db_uri: str):
34
+ """
35
+ Creates and caches a DatabaseManager instance for a given database name and URI.
36
+ If a database with the same name is already registered, it does nothing.
21
37
  """
22
- Executes a raw SQL statement and returns the result as a JSON string.
38
+ if db_name in self._db_connections:
39
+ return
23
40
 
24
- This method takes a DatabaseManager instance and a SQL query, executes it
25
- against the database, and fetches all results. The results are converted
26
- into a list of dictionaries, where each dictionary represents a row.
27
- This list is then serialized to a JSON string.
28
- If an exception occurs during execution, the transaction is rolled back,
29
- and a custom IAToolkitException is raised.
41
+ logging.debug(f"Registering and creating connection for database: '{db_name}'")
30
42
 
31
- Args:
32
- db_manager: The DatabaseManager instance to get the database session from.
33
- sql_statement: The raw SQL statement to be executed.
43
+ # create the database connection and save it on the cache
44
+ db_manager = DatabaseManager(db_uri, register_pgvector=False)
45
+ self._db_connections[db_name] = db_manager
34
46
 
35
- Returns:
36
- A JSON string representing the list of rows returned by the query.
47
+ def get_database_manager(self, db_name: str) -> DatabaseManager:
48
+ """
49
+ Retrieves a registered DatabaseManager instance from the cache.
37
50
  """
38
51
  try:
39
- # here the SQL is executed
40
- result = db_manager.get_session().execute(text(sql_statement))
52
+ return self._db_connections[db_name]
53
+ except KeyError:
54
+ logging.error(f"Attempted to access unregistered database: '{db_name}'")
55
+ raise IAToolkitException(
56
+ IAToolkitException.ErrorType.DATABASE_ERROR,
57
+ f"Database '{db_name}' is not registered with the SqlService."
58
+ )
41
59
 
42
- # get the column names
43
- cols = result.keys()
60
+ def exec_sql(self, database: str, query: str) -> str:
61
+ """
62
+ Executes a raw SQL statement against a registered database and returns the result as a JSON string.
63
+ """
64
+ try:
65
+ # 1. Get the database manager from the cache
66
+ db_manager = self.get_database_manager(database)
44
67
 
45
- # convert rows to dict
68
+ # 2. Execute the SQL statement
69
+ result = db_manager.get_session().execute(text(query))
70
+ cols = result.keys()
46
71
  rows_context = [dict(zip(cols, row)) for row in result.fetchall()]
47
72
 
48
- # Serialize to JSON with type convertion
73
+ # seialize the result
49
74
  sql_result_json = json.dumps(rows_context, default=self.util.serialize)
50
75
 
51
76
  return sql_result_json
77
+ except IAToolkitException:
78
+ # Re-raise exceptions from get_database_manager to preserve the specific error
79
+ raise
52
80
  except Exception as e:
53
- db_manager.get_session().rollback()
81
+ # Attempt to rollback if a session was active
82
+ db_manager = self._db_connections.get(database)
83
+ if db_manager:
84
+ db_manager.get_session().rollback()
54
85
 
55
86
  error_message = str(e)
56
87
  if 'timed out' in str(e):
57
- error_message = 'Intentalo de nuevo, se agoto el tiempo de espera'
88
+ error_message = self.i18n_service.t('errors.timeout')
58
89
 
90
+ logging.error(f"Error executing SQL statement: {error_message}")
59
91
  raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR,
60
92
  error_message) from e
@@ -6,8 +6,9 @@
6
6
  from iatoolkit.repositories.models import UserFeedback, Company
7
7
  from injector import inject
8
8
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
+ from iatoolkit.services.i18n_service import I18nService
9
10
  from iatoolkit.infra.google_chat_app import GoogleChatApp
10
- from iatoolkit.infra.mail_app import MailApp # <-- 1. Importar MailApp
11
+ from iatoolkit.infra.mail_app import MailApp
11
12
  import logging
12
13
 
13
14
 
@@ -15,9 +16,11 @@ class UserFeedbackService:
15
16
  @inject
16
17
  def __init__(self,
17
18
  profile_repo: ProfileRepo,
19
+ i18n_service: I18nService,
18
20
  google_chat_app: GoogleChatApp,
19
21
  mail_app: MailApp):
20
22
  self.profile_repo = profile_repo
23
+ self.i18n_service = i18n_service
21
24
  self.google_chat_app = google_chat_app
22
25
  self.mail_app = mail_app
23
26
 
@@ -31,9 +34,9 @@ class UserFeedbackService:
31
34
  }
32
35
  chat_result = self.google_chat_app.send_message(message_data=chat_data)
33
36
  if not chat_result.get('success'):
34
- logging.warning(f"Error al enviar notificación a Google Chat: {chat_result.get('message')}")
37
+ logging.warning(f"error sending notification to Google Chat: {chat_result.get('message')}")
35
38
  except Exception as e:
36
- logging.exception(f"Fallo inesperado al enviar notificación a Google Chat: {e}")
39
+ logging.exception(f"error sending notification to Google Chat: {e}")
37
40
 
38
41
  def _send_email_notification(self, destination_email: str, company_name: str, message_text: str):
39
42
  """Envía una notificación de feedback por correo electrónico."""
@@ -43,20 +46,20 @@ class UserFeedbackService:
43
46
  html_body = message_text.replace('\n', '<br>')
44
47
  self.mail_app.send_email(to=destination_email, subject=subject, body=html_body)
45
48
  except Exception as e:
46
- logging.exception(f"Fallo inesperado al enviar email de feedback: {e}")
49
+ logging.exception(f"error sending email de feedback: {e}")
47
50
 
48
51
  def _handle_notification(self, company: Company, message_text: str):
49
52
  """Lee la configuración de la empresa y envía la notificación al canal correspondiente."""
50
53
  feedback_params = company.parameters.get('user_feedback')
51
54
  if not isinstance(feedback_params, dict):
52
- logging.warning(f"No se encontró configuración de 'user_feedback' para la empresa {company.short_name}.")
55
+ logging.warning(f"missing 'user_feedback' configuration for company: {company.short_name}.")
53
56
  return
54
57
 
55
58
  # get channel and destination
56
59
  channel = feedback_params.get('channel')
57
60
  destination = feedback_params.get('destination')
58
61
  if not channel or not destination:
59
- logging.warning(f"Configuración 'user_feedback' incompleta para {company.short_name}. Faltan 'channel' o 'destination'.")
62
+ logging.warning(f"invalid 'user_feedback' configuration for: {company.short_name}. Faltan 'channel' o 'destination'.")
60
63
  return
61
64
 
62
65
  if channel == 'google_chat':
@@ -64,7 +67,7 @@ class UserFeedbackService:
64
67
  elif channel == 'email':
65
68
  self._send_email_notification(destination_email=destination, company_name=company.short_name, message_text=message_text)
66
69
  else:
67
- logging.warning(f"Canal de feedback '{channel}' no reconocido para la empresa {company.short_name}.")
70
+ logging.warning(f"unknown feedback channel: '{channel}' for company {company.short_name}.")
68
71
 
69
72
  def new_feedback(self,
70
73
  company_short_name: str,
@@ -72,19 +75,18 @@ class UserFeedbackService:
72
75
  user_identifier: str,
73
76
  rating: int = None) -> dict:
74
77
  try:
75
- # 1. Validar empresa
76
78
  company = self.profile_repo.get_company_by_short_name(company_short_name)
77
79
  if not company:
78
- return {'error': f'No existe la empresa: {company_short_name}'}
80
+ return {'error': self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
79
81
 
80
- # 2. Enviar notificación según la configuración de la empresa
82
+ # 2. send notification using company configuration
81
83
  notification_text = (f"*Nuevo feedback de {company_short_name}*:\n"
82
84
  f"*Usuario:* {user_identifier}\n"
83
85
  f"*Mensaje:* {message}\n"
84
86
  f"*Calificación:* {rating if rating is not None else 'N/A'}")
85
87
  self._handle_notification(company, notification_text)
86
88
 
87
- # 3. Guardar el feedback en la base de datos (independientemente del éxito de la notificación)
89
+ # 3. always save the feedback in the database
88
90
  new_feedback_obj = UserFeedback(
89
91
  company_id=company.id,
90
92
  message=message,
@@ -93,8 +95,8 @@ class UserFeedbackService:
93
95
  )
94
96
  saved_feedback = self.profile_repo.save_feedback(new_feedback_obj)
95
97
  if not saved_feedback:
96
- logging.error(f"No se pudo guardar el feedback para el usuario {user_identifier} en la empresa {company_short_name}")
97
- return {'error': 'No se pudo guardar el feedback'}
98
+ logging.error(f"can't save feedback for user {user_identifier}/{company_short_name}")
99
+ return {'error': 'can not save the feedback'}
98
100
 
99
101
  return {'success': True, 'message': 'Feedback guardado correctamente'}
100
102
 
@@ -25,8 +25,8 @@ $(document).ready(function () {
25
25
  const data = await callToolkit("/api/history", {}, "POST");
26
26
 
27
27
  if (!data || !data.history) {
28
+ historyLoading.hide();
28
29
  toastr.error(t_js('error_loading_history'));
29
- historyModal.modal('hide');
30
30
  return;
31
31
  }
32
32
 
@@ -60,7 +60,7 @@ $(document).ready(function () {
60
60
  filteredHistory.forEach((item, index) => {
61
61
  const icon = $('<i>').addClass('bi bi-pencil-fill');
62
62
 
63
- const link = $('<a>')
63
+ const edit_link = $('<a>')
64
64
  .attr('href', 'javascript:void(0);')
65
65
  .addClass('edit-pencil')
66
66
  .attr('title', t_js('edit_query'))
@@ -70,16 +70,14 @@ $(document).ready(function () {
70
70
  const row = $('<tr>').append(
71
71
  $('<td>').addClass('text-nowrap').text(formatDate(item.created_at)),
72
72
  $('<td>').text(item.query),
73
- $('<td>').append(link),
73
+ $('<td>').append(edit_link),
74
74
  );
75
-
76
75
  historyTableBody.append(row);
77
76
  });
78
77
  }
79
78
 
80
79
  function formatDate(dateString) {
81
80
  const date = new Date(dateString);
82
-
83
81
  const padTo2Digits = (num) => num.toString().padStart(2, '0');
84
82
 
85
83
  const day = padTo2Digits(date.getDate());
@@ -229,9 +229,7 @@ const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
229
229
 
230
230
  }
231
231
  const response = await fetch(url, fetchOptions);
232
-
233
232
  clearTimeout(timeoutId);
234
-
235
233
  if (!response.ok) {
236
234
  try {
237
235
  // Intentamos leer el error como JSON, que es el formato esperado de nuestra API.
@@ -244,9 +242,7 @@ const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
244
242
  // Si response.json() falla, es porque el cuerpo no era JSON (ej. un 502 con HTML).
245
243
  // Mostramos un error genérico y más claro para el usuario.
246
244
  const errorMessage = `Error de comunicación con el servidor (${response.status}). Por favor, intente de nuevo más tarde.`;
247
- const errorIcon = '<i class="bi bi-exclamation-triangle"></i>';
248
- const infrastructureError = $('<div>').addClass('error-section').html(errorIcon + `<p>${errorMessage}</p>`);
249
- displayBotMessage(infrastructureError);
245
+ toastr.error(errorMessage);
250
246
  }
251
247
  return null;
252
248
  }
@@ -256,18 +252,7 @@ const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
256
252
  if (error.name === 'AbortError') {
257
253
  throw error; // Re-throw to be handled by handleChatMessage
258
254
  } else {
259
- // Log detallado en consola
260
- console.error('Network error in callToolkit:', {
261
- url,
262
- method,
263
- error,
264
- message: error?.message,
265
- stack: error?.stack,
266
- });
267
- const friendlyMessage = t_js('network_error');
268
- const errorIcon = '<i class="bi bi-exclamation-triangle"></i>';
269
- const commError = $('<div>').addClass('error-section').html(errorIcon + `<p>${friendlyMessage}</p>`);
270
- displayBotMessage(commError);
255
+ toastr.error(t_js('network_error') );
271
256
  }
272
257
  return null;
273
258
  }