iatoolkit 0.11.0__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 (122) hide show
  1. iatoolkit/__init__.py +2 -6
  2. iatoolkit/base_company.py +9 -29
  3. iatoolkit/cli_commands.py +1 -1
  4. iatoolkit/common/routes.py +96 -52
  5. iatoolkit/common/session_manager.py +2 -1
  6. iatoolkit/common/util.py +17 -27
  7. iatoolkit/company_registry.py +1 -2
  8. iatoolkit/iatoolkit.py +97 -53
  9. iatoolkit/infra/llm_client.py +15 -20
  10. iatoolkit/infra/llm_proxy.py +38 -10
  11. iatoolkit/infra/openai_adapter.py +1 -1
  12. iatoolkit/infra/redis_session_manager.py +48 -2
  13. iatoolkit/locales/en.yaml +167 -0
  14. iatoolkit/locales/es.yaml +163 -0
  15. iatoolkit/repositories/database_manager.py +23 -3
  16. iatoolkit/repositories/document_repo.py +1 -1
  17. iatoolkit/repositories/models.py +35 -10
  18. iatoolkit/repositories/profile_repo.py +3 -2
  19. iatoolkit/repositories/vs_repo.py +26 -20
  20. iatoolkit/services/auth_service.py +193 -0
  21. iatoolkit/services/branding_service.py +70 -25
  22. iatoolkit/services/company_context_service.py +155 -0
  23. iatoolkit/services/configuration_service.py +133 -0
  24. iatoolkit/services/dispatcher_service.py +80 -105
  25. iatoolkit/services/document_service.py +5 -2
  26. iatoolkit/services/embedding_service.py +146 -0
  27. iatoolkit/services/excel_service.py +30 -26
  28. iatoolkit/services/file_processor_service.py +4 -12
  29. iatoolkit/services/history_service.py +7 -16
  30. iatoolkit/services/i18n_service.py +104 -0
  31. iatoolkit/services/jwt_service.py +18 -29
  32. iatoolkit/services/language_service.py +83 -0
  33. iatoolkit/services/load_documents_service.py +100 -113
  34. iatoolkit/services/mail_service.py +9 -4
  35. iatoolkit/services/profile_service.py +152 -76
  36. iatoolkit/services/prompt_manager_service.py +20 -16
  37. iatoolkit/services/query_service.py +208 -96
  38. iatoolkit/services/search_service.py +11 -4
  39. iatoolkit/services/sql_service.py +57 -25
  40. iatoolkit/services/tasks_service.py +1 -1
  41. iatoolkit/services/user_feedback_service.py +72 -34
  42. iatoolkit/services/user_session_context_service.py +112 -54
  43. iatoolkit/static/images/fernando.jpeg +0 -0
  44. iatoolkit/static/js/chat_feedback_button.js +80 -0
  45. iatoolkit/static/js/chat_help_content.js +124 -0
  46. iatoolkit/static/js/chat_history_button.js +110 -0
  47. iatoolkit/static/js/chat_logout_button.js +36 -0
  48. iatoolkit/static/js/chat_main.js +135 -222
  49. iatoolkit/static/js/chat_onboarding_button.js +103 -0
  50. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  51. iatoolkit/static/js/chat_reload_button.js +35 -0
  52. iatoolkit/static/styles/chat_iatoolkit.css +289 -210
  53. iatoolkit/static/styles/chat_modal.css +63 -77
  54. iatoolkit/static/styles/chat_public.css +107 -0
  55. iatoolkit/static/styles/landing_page.css +182 -0
  56. iatoolkit/static/styles/onboarding.css +176 -0
  57. iatoolkit/system_prompts/query_main.prompt +5 -22
  58. iatoolkit/templates/_company_header.html +20 -0
  59. iatoolkit/templates/_login_widget.html +42 -0
  60. iatoolkit/templates/base.html +40 -20
  61. iatoolkit/templates/change_password.html +57 -36
  62. iatoolkit/templates/chat.html +180 -86
  63. iatoolkit/templates/chat_modals.html +138 -68
  64. iatoolkit/templates/error.html +44 -8
  65. iatoolkit/templates/forgot_password.html +40 -23
  66. iatoolkit/templates/index.html +145 -0
  67. iatoolkit/templates/login_simulation.html +45 -0
  68. iatoolkit/templates/onboarding_shell.html +107 -0
  69. iatoolkit/templates/signup.html +63 -65
  70. iatoolkit/views/base_login_view.py +91 -0
  71. iatoolkit/views/change_password_view.py +56 -31
  72. iatoolkit/views/embedding_api_view.py +65 -0
  73. iatoolkit/views/external_login_view.py +61 -28
  74. iatoolkit/views/{file_store_view.py → file_store_api_view.py} +10 -3
  75. iatoolkit/views/forgot_password_view.py +27 -21
  76. iatoolkit/views/help_content_api_view.py +54 -0
  77. iatoolkit/views/history_api_view.py +56 -0
  78. iatoolkit/views/home_view.py +50 -23
  79. iatoolkit/views/index_view.py +14 -0
  80. iatoolkit/views/init_context_api_view.py +74 -0
  81. iatoolkit/views/llmquery_api_view.py +58 -0
  82. iatoolkit/views/login_simulation_view.py +93 -0
  83. iatoolkit/views/login_view.py +130 -37
  84. iatoolkit/views/logout_api_view.py +49 -0
  85. iatoolkit/views/profile_api_view.py +46 -0
  86. iatoolkit/views/{prompt_view.py → prompt_api_view.py} +10 -10
  87. iatoolkit/views/signup_view.py +41 -36
  88. iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
  89. iatoolkit/views/tasks_review_api_view.py +55 -0
  90. iatoolkit/views/user_feedback_api_view.py +60 -0
  91. iatoolkit/views/verify_user_view.py +34 -29
  92. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/METADATA +41 -23
  93. iatoolkit-0.71.2.dist-info/RECORD +122 -0
  94. iatoolkit-0.71.2.dist-info/licenses/LICENSE +21 -0
  95. iatoolkit/common/auth.py +0 -200
  96. iatoolkit/static/images/arrow_up.png +0 -0
  97. iatoolkit/static/images/diagrama_iatoolkit.jpg +0 -0
  98. iatoolkit/static/images/logo_clinica.png +0 -0
  99. iatoolkit/static/images/logo_iatoolkit.png +0 -0
  100. iatoolkit/static/images/logo_maxxa.png +0 -0
  101. iatoolkit/static/images/logo_notaria.png +0 -0
  102. iatoolkit/static/images/logo_tarjeta.png +0 -0
  103. iatoolkit/static/images/logo_umayor.png +0 -0
  104. iatoolkit/static/images/upload.png +0 -0
  105. iatoolkit/static/js/chat_feedback.js +0 -115
  106. iatoolkit/static/js/chat_history.js +0 -117
  107. iatoolkit/static/styles/chat_info.css +0 -53
  108. iatoolkit/templates/header.html +0 -31
  109. iatoolkit/templates/home.html +0 -199
  110. iatoolkit/templates/login.html +0 -43
  111. iatoolkit/templates/test.html +0 -9
  112. iatoolkit/views/chat_token_request_view.py +0 -98
  113. iatoolkit/views/chat_view.py +0 -58
  114. iatoolkit/views/download_file_view.py +0 -58
  115. iatoolkit/views/external_chat_login_view.py +0 -95
  116. iatoolkit/views/history_view.py +0 -57
  117. iatoolkit/views/llmquery_view.py +0 -65
  118. iatoolkit/views/tasks_review_view.py +0 -83
  119. iatoolkit/views/user_feedback_view.py +0 -74
  120. iatoolkit-0.11.0.dist-info/RECORD +0 -110
  121. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/WHEEL +0 -0
  122. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/top_level.txt +0 -0
@@ -4,11 +4,14 @@
4
4
  # IAToolkit is open source software.
5
5
 
6
6
  from iatoolkit.infra.llm_client import llmClient
7
+ from iatoolkit.services.profile_service import ProfileService
7
8
  from iatoolkit.repositories.document_repo import DocumentRepo
8
9
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
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
10
14
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
11
-
12
15
  from iatoolkit.repositories.models import Task
13
16
  from iatoolkit.services.dispatcher_service import Dispatcher
14
17
  from iatoolkit.services.prompt_manager_service import PromptService
@@ -21,6 +24,7 @@ import logging
21
24
  from typing import Optional
22
25
  import json
23
26
  import time
27
+ import hashlib
24
28
  import os
25
29
 
26
30
 
@@ -30,166 +34,248 @@ class QueryService:
30
34
  @inject
31
35
  def __init__(self,
32
36
  llm_client: llmClient,
37
+ profile_service: ProfileService,
38
+ company_context_service: CompanyContextService,
33
39
  document_service: DocumentService,
34
40
  document_repo: DocumentRepo,
35
41
  llmquery_repo: LLMQueryRepo,
36
42
  profile_repo: ProfileRepo,
37
43
  prompt_service: PromptService,
44
+ i18n_service: I18nService,
38
45
  util: Utility,
39
46
  dispatcher: Dispatcher,
40
- session_context: UserSessionContextService
47
+ session_context: UserSessionContextService,
48
+ configuration_service: ConfigurationService
41
49
  ):
50
+ self.profile_service = profile_service
51
+ self.company_context_service = company_context_service
42
52
  self.document_service = document_service
43
53
  self.document_repo = document_repo
44
54
  self.llmquery_repo = llmquery_repo
45
55
  self.profile_repo = profile_repo
46
56
  self.prompt_service = prompt_service
57
+ self.i18n_service = i18n_service
47
58
  self.util = util
48
59
  self.dispatcher = dispatcher
49
60
  self.session_context = session_context
61
+ self.configuration_service = configuration_service
50
62
  self.llm_client = llm_client
51
63
 
52
64
  # get the model from the environment variable
53
- self.model = os.getenv("LLM_MODEL", "")
54
- if not self.model:
65
+ self.default_model = os.getenv("LLM_MODEL", "")
66
+ if not self.default_model:
55
67
  raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
56
- "La variable de entorno 'LLM_MODEL' no está configurada.")
68
+ "missing ENV variable 'LLM_MODEL' configuration.")
57
69
 
70
+ def init_context(self, company_short_name: str,
71
+ user_identifier: str,
72
+ model: str = None) -> dict:
58
73
 
59
- def llm_init_context(self,
60
- company_short_name: str,
61
- external_user_id: str = None,
62
- local_user_id: int = 0,
63
- model: str = ''):
64
- start_time = time.time()
65
- if not model:
66
- model = self.model
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.")
67
77
 
68
- # Validate the user and company
69
- user_identifier, is_local_user = self.util.resolve_user_identifier(external_user_id, local_user_id)
70
- if not user_identifier:
71
- raise IAToolkitException(IAToolkitException.ErrorType.INVALID_USER,
72
- "No se pudo resolver el identificador del usuario")
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
+ )
73
90
 
91
+ return response
92
+
93
+ def _build_context_and_profile(self, company_short_name: str, user_identifier: str) -> tuple:
94
+ # this method read the user/company context from the database and renders the system prompt
74
95
  company = self.profile_repo.get_company_by_short_name(company_short_name)
75
96
  if not company:
76
- raise IAToolkitException(IAToolkitException.ErrorType.INVALID_NAME,
77
- f"Empresa no encontrada: {company_short_name}")
97
+ return None, None
98
+
99
+ # Get the user profile from the single source of truth.
100
+ user_profile = self.profile_service.get_profile_by_identifier(company_short_name, user_identifier)
101
+
102
+ # render the iatoolkit main system prompt with the company/user information
103
+ system_prompt_template = self.prompt_service.get_system_prompt()
104
+ rendered_system_prompt = self.util.render_prompt_from_string(
105
+ template_string=system_prompt_template,
106
+ question=None,
107
+ client_data=user_profile,
108
+ company=company,
109
+ service_list=self.dispatcher.get_company_services(company)
110
+ )
111
+
112
+ # get the company context: schemas, database models, .md files
113
+ company_specific_context = self.company_context_service.get_company_context(company_short_name)
114
+
115
+ # merge context: company + user
116
+ final_system_context = f"{company_specific_context}\n{rendered_system_prompt}"
117
+
118
+ return final_system_context, user_profile
119
+
120
+ def prepare_context(self, company_short_name: str, user_identifier: str) -> dict:
121
+ # prepare the context and decide if it needs to be rebuilt
122
+ # save the generated context in the session context for later use
123
+ if not user_identifier:
124
+ return {'rebuild_needed': True, 'error': 'Invalid user identifier'}
125
+
126
+ # create the company/user context and compute its version
127
+ final_system_context, user_profile = self._build_context_and_profile(
128
+ company_short_name, user_identifier)
129
+
130
+ # save the user information in the session context
131
+ # it's needed for the jinja predefined prompts (filtering)
132
+ self.session_context.save_profile_data(company_short_name, user_identifier, user_profile)
133
+
134
+ # calculate the context version
135
+ current_version = self._compute_context_version_from_string(final_system_context)
78
136
 
79
- logging.info(f"Inicializando contexto para {company_short_name}/{user_identifier} con modelo {model} ...")
80
137
  try:
81
- # 1. clean any previous context for company/user
82
- self.session_context.clear_all_context(
83
- company_short_name=company_short_name,
84
- user_identifier=user_identifier
85
- )
138
+ prev_version = self.session_context.get_context_version(company_short_name, user_identifier)
139
+ except Exception:
140
+ prev_version = None
86
141
 
87
- # 2. get dictionary with user information from company DB
88
- # user roles are read at this point from company db
89
- user_profile = self.dispatcher.get_user_info(
90
- company_name=company_short_name,
91
- user_identifier=user_identifier,
92
- is_local_user=is_local_user
93
- )
142
+ rebuild_is_needed = not (prev_version and prev_version == current_version and
143
+ self._has_valid_cached_context(company_short_name, user_identifier))
94
144
 
95
- # add the user logged in to the user_info dictionary
96
- user_profile['user_id'] = user_identifier
145
+ if rebuild_is_needed:
146
+ # Guardar el contexto preparado y su versión para que `finalize_context_rebuild` los use.
147
+ self.session_context.save_prepared_context(company_short_name,
148
+ user_identifier,
149
+ final_system_context,
150
+ current_version)
97
151
 
98
- # save the user information in the session context
99
- # it's needed for the jinja predefined prompts (filtering)
100
- self.session_context.save_user_session_data(company_short_name, user_identifier, user_profile)
152
+ return {'rebuild_needed': rebuild_is_needed}
101
153
 
102
- # 3. render the iatoolkit main system prompt with the company/user information
103
- system_prompt_template = self.prompt_service.get_system_prompt()
104
- rendered_system_prompt = self.util.render_prompt_from_string(
105
- template_string=system_prompt_template,
106
- question=None,
107
- client_data=user_profile,
108
- company=company,
109
- service_list=self.dispatcher.get_company_services(company)
110
- )
154
+ def set_context_for_llm(self,
155
+ company_short_name: str,
156
+ user_identifier: str,
157
+ model: str = ''):
111
158
 
112
- # 4. add more company context: schemas, database models, .md files
113
- company_specific_context = self.dispatcher.get_company_context(company_name=company_short_name)
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
114
164
 
115
- # 5. merge contexts
116
- final_system_context = f"{company_specific_context}\n{rendered_system_prompt}"
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']
172
+
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
176
+ lock_key = f"lock:context:{company_short_name}/{user_identifier}"
177
+ if not self.session_context.acquire_lock(lock_key, expire_seconds=60):
178
+ logging.warning(
179
+ f"try to rebuild context for user {user_identifier} while is still in process, ignored.")
180
+ return
117
181
 
118
- if self.util.is_gemini_model(model):
119
- # save the initial context as `context_history` (list of messages)
120
- context_history = [{"role": "user", "content": final_system_context}]
121
- self.session_context.save_context_history(company_short_name, user_identifier, context_history)
122
- logging.info(f"Contexto inicial para Gemini guardado en sesión")
123
- return "gemini-context-initialized"
182
+ try:
183
+ start_time = time.time()
184
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
185
+
186
+ # get the prepared context and version from the session cache
187
+ prepared_context, version_to_save = self.session_context.get_and_clear_prepared_context(company_short_name,
188
+ user_identifier)
189
+ if not prepared_context:
190
+ return
124
191
 
125
- elif self.util.is_openai_model(model):
192
+ logging.info(f"sending context to LLM model {effective_model} for: {company_short_name}/{user_identifier}...")
126
193
 
127
- # 6. set the company/user context as the initial context for the LLM
194
+ # clean only the chat history and the last response ID for this user/company
195
+ self.session_context.clear_llm_history(company_short_name, user_identifier)
196
+
197
+ response_id = ''
198
+ if self.util.is_gemini_model(effective_model):
199
+ context_history = [{"role": "user", "content": prepared_context}]
200
+ self.session_context.save_context_history(company_short_name, user_identifier, context_history)
201
+ elif self.util.is_openai_model(effective_model):
202
+ # Here is the call to the LLM client for settling the company/user context
128
203
  response_id = self.llm_client.set_company_context(
129
204
  company=company,
130
- company_base_context=final_system_context,
131
- model=model
205
+ company_base_context=prepared_context,
206
+ model=effective_model
132
207
  )
133
-
134
- # 7. save response_id in the session context
135
208
  self.session_context.save_last_response_id(company_short_name, user_identifier, response_id)
136
209
 
137
- logging.info(f"Contexto inicial de company '{company_short_name}/{user_identifier}' ha sido establecido en {int(time.time() - start_time)} seg.")
138
- return response_id
210
+ if version_to_save:
211
+ self.session_context.save_context_version(company_short_name, user_identifier, version_to_save)
139
212
 
213
+ logging.info(
214
+ f"Context for: {company_short_name}/{user_identifier} settled in {int(time.time() - start_time)} sec.")
140
215
  except Exception as e:
141
- logging.exception(f"Error al inicializar el contexto del LLM para {company_short_name}: {e}")
216
+ logging.exception(f"Error in finalize_context_rebuild for {company_short_name}: {e}")
142
217
  raise e
218
+ finally:
219
+ # release the lock
220
+ self.session_context.release_lock(lock_key)
221
+
222
+ return {'response_id': response_id }
143
223
 
144
224
  def llm_query(self,
145
225
  company_short_name: str,
146
- external_user_id: Optional[str] = None,
147
- local_user_id: int = 0,
226
+ user_identifier: str,
148
227
  task: Optional[Task] = None,
149
228
  prompt_name: str = None,
150
229
  question: str = '',
151
230
  client_data: dict = {},
152
- files: list = []) -> dict:
231
+ response_id: str = '',
232
+ files: list = [],
233
+ model: Optional[str] = None) -> dict:
153
234
  try:
154
- user_identifier, is_local_user = self.util.resolve_user_identifier(external_user_id, local_user_id)
155
- if not user_identifier:
156
- return {"error": True,
157
- "error_message": "No se pudo identificar al usuario"}
158
-
159
235
  company = self.profile_repo.get_company_by_short_name(short_name=company_short_name)
160
236
  if not company:
161
237
  return {"error": True,
162
- "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)}
163
239
 
164
240
  if not prompt_name and not question:
165
241
  return {"error": True,
166
- "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
167
253
 
168
254
  # get the previous response_id and context history
169
255
  previous_response_id = None
170
256
  context_history = self.session_context.get_context_history(company.short_name, user_identifier) or []
171
257
 
172
- if self.util.is_openai_model(self.model):
173
- # get user context
174
- previous_response_id = self.session_context.get_last_response_id(company.short_name, user_identifier)
175
- if not previous_response_id:
176
- # try to initialize the company/user context
177
- previous_response_id = self.llm_init_context(company.short_name, external_user_id, local_user_id)
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)
178
265
  if not previous_response_id:
179
266
  return {'error': True,
180
- "error_message": f"FATAL: No se encontró 'previous_response_id' para '{company.short_name}/{user_identifier}'. La conversación no puede continuar."
267
+ "error_message": self.i18n_service.t('errors.services.missing_response_id', company_short_name=company.short_name, user_identifier=user_identifier)
181
268
  }
182
- elif self.util.is_gemini_model(self.model):
269
+ elif self.util.is_gemini_model(effective_model):
183
270
  # check the length of the context_history and remove old messages
184
271
  self._trim_context_history(context_history)
185
272
 
186
- # get the user data from the session context
187
- user_info_from_session = self.session_context.get_user_session_data(company.short_name, user_identifier)
273
+ # get the user profile data from the session context
274
+ user_profile = self.profile_service.get_profile_by_identifier(company.short_name, user_identifier)
188
275
 
189
- # Combinar datos: los datos de la tarea/request tienen prioridad sobre los de la sesión
190
- final_client_data = (user_info_from_session or {}).copy()
276
+ # combine client_data with user_profile
277
+ final_client_data = (user_profile or {}).copy()
191
278
  final_client_data.update(client_data)
192
- final_client_data['user_id'] = user_identifier
193
279
 
194
280
  # Load attached files into the context
195
281
  files_context = self.load_files_for_context(files)
@@ -207,7 +293,7 @@ class QueryService:
207
293
  template_string=prompt_content,
208
294
  question=question,
209
295
  client_data=final_client_data,
210
- external_user_id=external_user_id,
296
+ user_identifier=user_identifier,
211
297
  company=company,
212
298
  )
213
299
 
@@ -219,7 +305,7 @@ class QueryService:
219
305
  user_turn_prompt += f'\n### Contexto Adicional: El usuario ha aportado este contexto puede ayudar: {question}'
220
306
 
221
307
  # add to the history context
222
- if self.util.is_gemini_model(self.model):
308
+ if self.util.is_gemini_model(effective_model):
223
309
  context_history.append({"role": "user", "content": user_turn_prompt})
224
310
 
225
311
  # service list for the function calls
@@ -232,8 +318,9 @@ class QueryService:
232
318
  response = self.llm_client.invoke(
233
319
  company=company,
234
320
  user_identifier=user_identifier,
321
+ model=effective_model,
235
322
  previous_response_id=previous_response_id,
236
- 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,
237
324
  question=question,
238
325
  context=user_turn_prompt,
239
326
  tools=tools,
@@ -246,7 +333,7 @@ class QueryService:
246
333
  # save last_response_id for the history chain
247
334
  if "response_id" in response:
248
335
  self.session_context.save_last_response_id(company.short_name, user_identifier, response["response_id"])
249
- if self.util.is_gemini_model(self.model):
336
+ if self.util.is_gemini_model(effective_model):
250
337
  self.session_context.save_context_history(company.short_name, user_identifier, context_history)
251
338
 
252
339
  return response
@@ -254,6 +341,31 @@ class QueryService:
254
341
  logging.exception(e)
255
342
  return {'error': True, "error_message": f"{str(e)}"}
256
343
 
344
+ def _compute_context_version_from_string(self, final_system_context: str) -> str:
345
+ # returns a hash of the context string
346
+ try:
347
+ return hashlib.sha256(final_system_context.encode("utf-8")).hexdigest()
348
+ except Exception:
349
+ return "unknown"
350
+
351
+ def _has_valid_cached_context(self, company_short_name: str, user_identifier: str) -> bool:
352
+ """
353
+ Verifica si existe un estado de contexto reutilizable en sesión.
354
+ - OpenAI: last_response_id presente.
355
+ - Gemini: context_history con al menos 1 mensaje.
356
+ """
357
+ try:
358
+ if self.util.is_openai_model(self.default_model):
359
+ prev_id = self.session_context.get_last_response_id(company_short_name, user_identifier)
360
+ return bool(prev_id)
361
+ if self.util.is_gemini_model(self.default_model):
362
+ history = self.session_context.get_context_history(company_short_name, user_identifier) or []
363
+ return len(history) >= 1
364
+ return False
365
+ except Exception as e:
366
+ logging.warning(f"error verifying context cache: {e}")
367
+ return False
368
+
257
369
  def load_files_for_context(self, files: list) -> str:
258
370
  """
259
371
  Processes a list of attached files, decodes their content,
@@ -310,7 +422,7 @@ class QueryService:
310
422
  try:
311
423
  total_tokens = sum(self.llm_client.count_tokens(json.dumps(message)) for message in context_history)
312
424
  except Exception as e:
313
- 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}.")
314
426
  return
315
427
 
316
428
  # Si se excede el límite, eliminar mensajes antiguos (empezando por el segundo)
@@ -321,8 +433,8 @@ class QueryService:
321
433
  removed_tokens = self.llm_client.count_tokens(json.dumps(removed_message))
322
434
  total_tokens -= removed_tokens
323
435
  logging.warning(
324
- f"Historial de contexto ({total_tokens + removed_tokens} tokens) excedía el límite de {GEMINI_MAX_TOKENS_CONTEXT_HISTORY}. "
325
- 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."
326
438
  )
327
439
  except IndexError:
328
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
@@ -101,7 +101,7 @@ class TaskService:
101
101
  # call the IA
102
102
  response = self.query_service.llm_query(
103
103
  task=task,
104
- local_user_id=0,
104
+ user_identifier='task-monitor',
105
105
  company_short_name=task.company.short_name,
106
106
  prompt_name=task.task_type.name,
107
107
  client_data=task.client_data,