iatoolkit 0.66.2__py3-none-any.whl → 0.71.4__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.
- iatoolkit/__init__.py +2 -6
- iatoolkit/base_company.py +3 -31
- iatoolkit/cli_commands.py +1 -1
- iatoolkit/common/routes.py +5 -1
- iatoolkit/common/session_manager.py +2 -0
- iatoolkit/company_registry.py +1 -2
- iatoolkit/iatoolkit.py +13 -13
- iatoolkit/infra/llm_client.py +8 -12
- iatoolkit/infra/llm_proxy.py +38 -10
- iatoolkit/locales/en.yaml +25 -2
- iatoolkit/locales/es.yaml +27 -4
- iatoolkit/repositories/database_manager.py +8 -3
- iatoolkit/repositories/document_repo.py +1 -1
- iatoolkit/repositories/models.py +6 -8
- iatoolkit/repositories/profile_repo.py +0 -4
- iatoolkit/repositories/vs_repo.py +26 -20
- iatoolkit/services/auth_service.py +2 -2
- iatoolkit/services/branding_service.py +11 -7
- iatoolkit/services/company_context_service.py +155 -0
- iatoolkit/services/configuration_service.py +133 -0
- iatoolkit/services/dispatcher_service.py +75 -70
- iatoolkit/services/document_service.py +5 -2
- iatoolkit/services/embedding_service.py +145 -0
- iatoolkit/services/excel_service.py +15 -11
- iatoolkit/services/file_processor_service.py +4 -12
- iatoolkit/services/history_service.py +7 -7
- iatoolkit/services/i18n_service.py +4 -4
- iatoolkit/services/jwt_service.py +7 -9
- iatoolkit/services/language_service.py +29 -23
- iatoolkit/services/load_documents_service.py +100 -113
- iatoolkit/services/mail_service.py +9 -4
- iatoolkit/services/profile_service.py +10 -7
- iatoolkit/services/prompt_manager_service.py +20 -16
- iatoolkit/services/query_service.py +112 -43
- iatoolkit/services/search_service.py +11 -4
- iatoolkit/services/sql_service.py +57 -25
- iatoolkit/services/user_feedback_service.py +15 -13
- iatoolkit/static/js/chat_history_button.js +3 -5
- iatoolkit/static/js/chat_main.js +2 -17
- iatoolkit/static/js/chat_onboarding_button.js +6 -0
- iatoolkit/static/styles/chat_iatoolkit.css +69 -158
- iatoolkit/static/styles/chat_modal.css +1 -37
- iatoolkit/static/styles/onboarding.css +7 -0
- iatoolkit/system_prompts/query_main.prompt +2 -10
- iatoolkit/templates/change_password.html +1 -1
- iatoolkit/templates/chat.html +12 -4
- iatoolkit/templates/chat_modals.html +4 -0
- iatoolkit/templates/error.html +1 -1
- iatoolkit/templates/login_simulation.html +17 -6
- iatoolkit/templates/onboarding_shell.html +4 -1
- iatoolkit/views/base_login_view.py +7 -8
- iatoolkit/views/change_password_view.py +2 -3
- iatoolkit/views/embedding_api_view.py +65 -0
- iatoolkit/views/external_login_view.py +1 -1
- iatoolkit/views/file_store_api_view.py +1 -1
- iatoolkit/views/forgot_password_view.py +2 -4
- iatoolkit/views/help_content_api_view.py +9 -9
- iatoolkit/views/history_api_view.py +1 -1
- iatoolkit/views/home_view.py +2 -2
- iatoolkit/views/init_context_api_view.py +18 -17
- iatoolkit/views/llmquery_api_view.py +3 -2
- iatoolkit/views/login_simulation_view.py +14 -2
- iatoolkit/views/login_view.py +9 -9
- iatoolkit/views/signup_view.py +2 -4
- iatoolkit/views/verify_user_view.py +2 -4
- {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.4.dist-info}/METADATA +40 -22
- iatoolkit-0.71.4.dist-info/RECORD +122 -0
- iatoolkit-0.71.4.dist-info/licenses/LICENSE +21 -0
- iatoolkit/services/help_content_service.py +0 -30
- iatoolkit/services/onboarding_service.py +0 -43
- iatoolkit-0.66.2.dist-info/RECORD +0 -119
- {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.4.dist-info}/WHEEL +0 -0
- {iatoolkit-0.66.2.dist-info → iatoolkit-0.71.4.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.
|
|
57
|
-
if not self.
|
|
65
|
+
self.default_model = os.getenv("LLM_MODEL", "")
|
|
66
|
+
if not self.default_model:
|
|
58
67
|
raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
|
|
59
|
-
"
|
|
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.
|
|
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,
|
|
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
|
|
154
|
+
def set_context_for_llm(self,
|
|
155
|
+
company_short_name: str,
|
|
156
|
+
user_identifier: str,
|
|
157
|
+
model: str = ''):
|
|
121
158
|
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
192
|
+
logging.info(f"sending context to LLM model {effective_model} for: {company_short_name}/{user_identifier}...")
|
|
146
193
|
|
|
147
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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"
|
|
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
|
|
216
|
+
logging.exception(f"Error in finalize_context_rebuild for {company_short_name}: {e}")
|
|
167
217
|
raise e
|
|
168
218
|
finally:
|
|
169
|
-
#
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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"
|
|
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"
|
|
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"
|
|
368
|
-
f"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
38
|
+
if db_name in self._db_connections:
|
|
39
|
+
return
|
|
23
40
|
|
|
24
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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 = '
|
|
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
|
|
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"
|
|
37
|
+
logging.warning(f"error sending notification to Google Chat: {chat_result.get('message')}")
|
|
35
38
|
except Exception as e:
|
|
36
|
-
logging.exception(f"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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':
|
|
80
|
+
return {'error': self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
|
|
79
81
|
|
|
80
|
-
# 2.
|
|
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.
|
|
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"
|
|
97
|
-
return {'error': '
|
|
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
|
|
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(
|
|
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());
|
iatoolkit/static/js/chat_main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|