iatoolkit 0.11.0__py3-none-any.whl → 0.66.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 (106) hide show
  1. iatoolkit/base_company.py +11 -3
  2. iatoolkit/common/routes.py +92 -52
  3. iatoolkit/common/session_manager.py +0 -1
  4. iatoolkit/common/util.py +17 -27
  5. iatoolkit/iatoolkit.py +91 -47
  6. iatoolkit/infra/llm_client.py +7 -8
  7. iatoolkit/infra/openai_adapter.py +1 -1
  8. iatoolkit/infra/redis_session_manager.py +48 -2
  9. iatoolkit/locales/en.yaml +144 -0
  10. iatoolkit/locales/es.yaml +140 -0
  11. iatoolkit/repositories/database_manager.py +17 -2
  12. iatoolkit/repositories/models.py +31 -4
  13. iatoolkit/repositories/profile_repo.py +7 -2
  14. iatoolkit/services/auth_service.py +193 -0
  15. iatoolkit/services/branding_service.py +59 -18
  16. iatoolkit/services/dispatcher_service.py +10 -40
  17. iatoolkit/services/excel_service.py +15 -15
  18. iatoolkit/services/help_content_service.py +30 -0
  19. iatoolkit/services/history_service.py +2 -11
  20. iatoolkit/services/i18n_service.py +104 -0
  21. iatoolkit/services/jwt_service.py +15 -24
  22. iatoolkit/services/language_service.py +77 -0
  23. iatoolkit/services/onboarding_service.py +43 -0
  24. iatoolkit/services/profile_service.py +148 -75
  25. iatoolkit/services/query_service.py +124 -81
  26. iatoolkit/services/tasks_service.py +1 -1
  27. iatoolkit/services/user_feedback_service.py +68 -32
  28. iatoolkit/services/user_session_context_service.py +112 -54
  29. iatoolkit/static/images/fernando.jpeg +0 -0
  30. iatoolkit/static/js/chat_feedback_button.js +80 -0
  31. iatoolkit/static/js/chat_help_content.js +124 -0
  32. iatoolkit/static/js/chat_history_button.js +112 -0
  33. iatoolkit/static/js/chat_logout_button.js +36 -0
  34. iatoolkit/static/js/chat_main.js +148 -220
  35. iatoolkit/static/js/chat_onboarding_button.js +97 -0
  36. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  37. iatoolkit/static/js/chat_reload_button.js +35 -0
  38. iatoolkit/static/styles/chat_iatoolkit.css +367 -199
  39. iatoolkit/static/styles/chat_modal.css +98 -76
  40. iatoolkit/static/styles/chat_public.css +107 -0
  41. iatoolkit/static/styles/landing_page.css +182 -0
  42. iatoolkit/static/styles/onboarding.css +169 -0
  43. iatoolkit/system_prompts/query_main.prompt +3 -12
  44. iatoolkit/templates/_company_header.html +20 -0
  45. iatoolkit/templates/_login_widget.html +42 -0
  46. iatoolkit/templates/base.html +40 -20
  47. iatoolkit/templates/change_password.html +57 -36
  48. iatoolkit/templates/chat.html +169 -83
  49. iatoolkit/templates/chat_modals.html +134 -68
  50. iatoolkit/templates/error.html +44 -8
  51. iatoolkit/templates/forgot_password.html +40 -23
  52. iatoolkit/templates/index.html +145 -0
  53. iatoolkit/templates/login_simulation.html +34 -0
  54. iatoolkit/templates/onboarding_shell.html +104 -0
  55. iatoolkit/templates/signup.html +63 -65
  56. iatoolkit/views/base_login_view.py +92 -0
  57. iatoolkit/views/change_password_view.py +56 -30
  58. iatoolkit/views/external_login_view.py +61 -28
  59. iatoolkit/views/{file_store_view.py → file_store_api_view.py} +9 -2
  60. iatoolkit/views/forgot_password_view.py +27 -19
  61. iatoolkit/views/help_content_api_view.py +54 -0
  62. iatoolkit/views/history_api_view.py +56 -0
  63. iatoolkit/views/home_view.py +50 -23
  64. iatoolkit/views/index_view.py +14 -0
  65. iatoolkit/views/init_context_api_view.py +73 -0
  66. iatoolkit/views/llmquery_api_view.py +57 -0
  67. iatoolkit/views/login_simulation_view.py +81 -0
  68. iatoolkit/views/login_view.py +130 -37
  69. iatoolkit/views/logout_api_view.py +49 -0
  70. iatoolkit/views/profile_api_view.py +46 -0
  71. iatoolkit/views/{prompt_view.py → prompt_api_view.py} +10 -10
  72. iatoolkit/views/signup_view.py +42 -35
  73. iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
  74. iatoolkit/views/tasks_review_api_view.py +55 -0
  75. iatoolkit/views/user_feedback_api_view.py +60 -0
  76. iatoolkit/views/verify_user_view.py +35 -28
  77. {iatoolkit-0.11.0.dist-info → iatoolkit-0.66.2.dist-info}/METADATA +2 -2
  78. iatoolkit-0.66.2.dist-info/RECORD +119 -0
  79. iatoolkit/common/auth.py +0 -200
  80. iatoolkit/static/images/arrow_up.png +0 -0
  81. iatoolkit/static/images/diagrama_iatoolkit.jpg +0 -0
  82. iatoolkit/static/images/logo_clinica.png +0 -0
  83. iatoolkit/static/images/logo_iatoolkit.png +0 -0
  84. iatoolkit/static/images/logo_maxxa.png +0 -0
  85. iatoolkit/static/images/logo_notaria.png +0 -0
  86. iatoolkit/static/images/logo_tarjeta.png +0 -0
  87. iatoolkit/static/images/logo_umayor.png +0 -0
  88. iatoolkit/static/images/upload.png +0 -0
  89. iatoolkit/static/js/chat_feedback.js +0 -115
  90. iatoolkit/static/js/chat_history.js +0 -117
  91. iatoolkit/static/styles/chat_info.css +0 -53
  92. iatoolkit/templates/header.html +0 -31
  93. iatoolkit/templates/home.html +0 -199
  94. iatoolkit/templates/login.html +0 -43
  95. iatoolkit/templates/test.html +0 -9
  96. iatoolkit/views/chat_token_request_view.py +0 -98
  97. iatoolkit/views/chat_view.py +0 -58
  98. iatoolkit/views/download_file_view.py +0 -58
  99. iatoolkit/views/external_chat_login_view.py +0 -95
  100. iatoolkit/views/history_view.py +0 -57
  101. iatoolkit/views/llmquery_view.py +0 -65
  102. iatoolkit/views/tasks_review_view.py +0 -83
  103. iatoolkit/views/user_feedback_view.py +0 -74
  104. iatoolkit-0.11.0.dist-info/RECORD +0 -110
  105. {iatoolkit-0.11.0.dist-info → iatoolkit-0.66.2.dist-info}/WHEEL +0 -0
  106. {iatoolkit-0.11.0.dist-info → iatoolkit-0.66.2.dist-info}/top_level.txt +0 -0
@@ -4,11 +4,11 @@
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
10
11
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
11
-
12
12
  from iatoolkit.repositories.models import Task
13
13
  from iatoolkit.services.dispatcher_service import Dispatcher
14
14
  from iatoolkit.services.prompt_manager_service import PromptService
@@ -21,6 +21,7 @@ import logging
21
21
  from typing import Optional
22
22
  import json
23
23
  import time
24
+ import hashlib
24
25
  import os
25
26
 
26
27
 
@@ -30,6 +31,7 @@ class QueryService:
30
31
  @inject
31
32
  def __init__(self,
32
33
  llm_client: llmClient,
34
+ profile_service: ProfileService,
33
35
  document_service: DocumentService,
34
36
  document_repo: DocumentRepo,
35
37
  llmquery_repo: LLMQueryRepo,
@@ -39,6 +41,7 @@ class QueryService:
39
41
  dispatcher: Dispatcher,
40
42
  session_context: UserSessionContextService
41
43
  ):
44
+ self.profile_service = profile_service
42
45
  self.document_service = document_service
43
46
  self.document_repo = document_repo
44
47
  self.llmquery_repo = llmquery_repo
@@ -55,107 +58,126 @@ class QueryService:
55
58
  raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
56
59
  "La variable de entorno 'LLM_MODEL' no está configurada.")
57
60
 
61
+ def _build_context_and_profile(self, company_short_name: str, user_identifier: str) -> tuple:
62
+ # this method read the user/company context from the database and renders the system prompt
63
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
64
+ if not company:
65
+ return None, None
58
66
 
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
67
+ # Get the user profile from the single source of truth.
68
+ user_profile = self.profile_service.get_profile_by_identifier(company_short_name, user_identifier)
67
69
 
68
- # Validate the user and company
69
- user_identifier, is_local_user = self.util.resolve_user_identifier(external_user_id, local_user_id)
70
+ # render the iatoolkit main system prompt with the company/user information
71
+ system_prompt_template = self.prompt_service.get_system_prompt()
72
+ rendered_system_prompt = self.util.render_prompt_from_string(
73
+ template_string=system_prompt_template,
74
+ question=None,
75
+ client_data=user_profile,
76
+ company=company,
77
+ service_list=self.dispatcher.get_company_services(company)
78
+ )
79
+
80
+ # get the company context: schemas, database models, .md files
81
+ company_specific_context = self.dispatcher.get_company_context(company_name=company_short_name)
82
+
83
+ # merge context: company + user
84
+ final_system_context = f"{company_specific_context}\n{rendered_system_prompt}"
85
+
86
+ return final_system_context, user_profile
87
+
88
+ def prepare_context(self, company_short_name: str, user_identifier: str) -> dict:
89
+ # prepare the context and decide if it needs to be rebuilt
90
+ # save the generated context in the session context for later use
70
91
  if not user_identifier:
71
- raise IAToolkitException(IAToolkitException.ErrorType.INVALID_USER,
72
- "No se pudo resolver el identificador del usuario")
92
+ return {'rebuild_needed': True, 'error': 'Invalid user identifier'}
73
93
 
74
- company = self.profile_repo.get_company_by_short_name(company_short_name)
75
- if not company:
76
- raise IAToolkitException(IAToolkitException.ErrorType.INVALID_NAME,
77
- f"Empresa no encontrada: {company_short_name}")
94
+ # create the company/user context and compute its version
95
+ final_system_context, user_profile = self._build_context_and_profile(
96
+ company_short_name, user_identifier)
97
+
98
+ # save the user information in the session context
99
+ # it's needed for the jinja predefined prompts (filtering)
100
+ self.session_context.save_profile_data(company_short_name, user_identifier, user_profile)
101
+
102
+ # calculate the context version
103
+ current_version = self._compute_context_version_from_string(final_system_context)
78
104
 
79
- logging.info(f"Inicializando contexto para {company_short_name}/{user_identifier} con modelo {model} ...")
80
105
  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
- )
106
+ prev_version = self.session_context.get_context_version(company_short_name, user_identifier)
107
+ except Exception:
108
+ prev_version = None
86
109
 
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
- )
110
+ rebuild_is_needed = not (prev_version and prev_version == current_version and
111
+ self._has_valid_cached_context(company_short_name, user_identifier))
94
112
 
95
- # add the user logged in to the user_info dictionary
96
- user_profile['user_id'] = user_identifier
113
+ if rebuild_is_needed:
114
+ # 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,
116
+ current_version)
97
117
 
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)
118
+ return {'rebuild_needed': rebuild_is_needed}
101
119
 
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
- )
120
+ def finalize_context_rebuild(self, company_short_name: str, user_identifier: str, model: str = ''):
121
+
122
+ # end the initilization, if there is a prepare context send it to llm
123
+ if not model:
124
+ model = self.model
125
+
126
+ # --- Lógica de Bloqueo ---
127
+ lock_key = f"lock:context:{company_short_name}/{user_identifier}"
128
+ if not self.session_context.acquire_lock(lock_key, expire_seconds=60):
129
+ logging.warning(
130
+ f"Intento de reconstruir contexto para {user_identifier} mientras ya estaba en progreso. Se omite.")
131
+ return
132
+
133
+ try:
134
+ start_time = time.time()
135
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
111
136
 
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)
137
+ # get the prepared context and version from the session cache
138
+ prepared_context, version_to_save = self.session_context.get_and_clear_prepared_context(company_short_name,
139
+ user_identifier)
140
+ 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
+ return
114
144
 
115
- # 5. merge contexts
116
- final_system_context = f"{company_specific_context}\n{rendered_system_prompt}"
145
+ logging.info(f"Enviando contexto al LLM para {company_short_name}/{user_identifier}...")
146
+
147
+ # Limpiar solo el historial de chat y el ID de respuesta anterior
148
+ self.session_context.clear_llm_history(company_short_name, user_identifier)
117
149
 
118
150
  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}]
151
+ context_history = [{"role": "user", "content": prepared_context}]
121
152
  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"
124
153
 
125
154
  elif self.util.is_openai_model(model):
126
-
127
- # 6. set the company/user context as the initial context for the LLM
128
155
  response_id = self.llm_client.set_company_context(
129
- company=company,
130
- company_base_context=final_system_context,
131
- model=model
156
+ company=company, company_base_context=prepared_context, model=model
132
157
  )
133
-
134
- # 7. save response_id in the session context
135
158
  self.session_context.save_last_response_id(company_short_name, user_identifier, response_id)
136
159
 
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
160
+ if version_to_save:
161
+ self.session_context.save_context_version(company_short_name, user_identifier, version_to_save)
139
162
 
163
+ logging.info(
164
+ f"Contexto de {company_short_name}/{user_identifier} establecido en {int(time.time() - start_time)} seg.")
140
165
  except Exception as e:
141
- logging.exception(f"Error al inicializar el contexto del LLM para {company_short_name}: {e}")
166
+ logging.exception(f"Error en finalize_context_rebuild para {company_short_name}: {e}")
142
167
  raise e
168
+ finally:
169
+ # --- Liberar el Bloqueo ---
170
+ self.session_context.release_lock(lock_key)
143
171
 
144
172
  def llm_query(self,
145
173
  company_short_name: str,
146
- external_user_id: Optional[str] = None,
147
- local_user_id: int = 0,
174
+ user_identifier: str,
148
175
  task: Optional[Task] = None,
149
176
  prompt_name: str = None,
150
177
  question: str = '',
151
178
  client_data: dict = {},
152
179
  files: list = []) -> dict:
153
180
  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
181
  company = self.profile_repo.get_company_by_short_name(short_name=company_short_name)
160
182
  if not company:
161
183
  return {"error": True,
@@ -173,23 +195,19 @@ class QueryService:
173
195
  # get user context
174
196
  previous_response_id = self.session_context.get_last_response_id(company.short_name, user_identifier)
175
197
  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)
178
- if not previous_response_id:
179
- 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."
181
- }
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
+ }
182
201
  elif self.util.is_gemini_model(self.model):
183
202
  # check the length of the context_history and remove old messages
184
203
  self._trim_context_history(context_history)
185
204
 
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)
205
+ # get the user profile data from the session context
206
+ user_profile = self.profile_service.get_profile_by_identifier(company.short_name, user_identifier)
188
207
 
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()
208
+ # combine client_data with user_profile
209
+ final_client_data = (user_profile or {}).copy()
191
210
  final_client_data.update(client_data)
192
- final_client_data['user_id'] = user_identifier
193
211
 
194
212
  # Load attached files into the context
195
213
  files_context = self.load_files_for_context(files)
@@ -207,7 +225,7 @@ class QueryService:
207
225
  template_string=prompt_content,
208
226
  question=question,
209
227
  client_data=final_client_data,
210
- external_user_id=external_user_id,
228
+ user_identifier=user_identifier,
211
229
  company=company,
212
230
  )
213
231
 
@@ -254,6 +272,31 @@ class QueryService:
254
272
  logging.exception(e)
255
273
  return {'error': True, "error_message": f"{str(e)}"}
256
274
 
275
+ def _compute_context_version_from_string(self, final_system_context: str) -> str:
276
+ # returns a hash of the context string
277
+ try:
278
+ return hashlib.sha256(final_system_context.encode("utf-8")).hexdigest()
279
+ except Exception:
280
+ return "unknown"
281
+
282
+ def _has_valid_cached_context(self, company_short_name: str, user_identifier: str) -> bool:
283
+ """
284
+ Verifica si existe un estado de contexto reutilizable en sesión.
285
+ - OpenAI: last_response_id presente.
286
+ - Gemini: context_history con al menos 1 mensaje.
287
+ """
288
+ try:
289
+ if self.util.is_openai_model(self.model):
290
+ prev_id = self.session_context.get_last_response_id(company_short_name, user_identifier)
291
+ return bool(prev_id)
292
+ if self.util.is_gemini_model(self.model):
293
+ history = self.session_context.get_context_history(company_short_name, user_identifier) or []
294
+ return len(history) >= 1
295
+ return False
296
+ except Exception as e:
297
+ logging.warning(f"Error verificando caché de contexto: {e}")
298
+ return False
299
+
257
300
  def load_files_for_context(self, files: list) -> str:
258
301
  """
259
302
  Processes a list of attached files, decodes their content,
@@ -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,
@@ -3,65 +3,101 @@
3
3
  #
4
4
  # IAToolkit is open source software.
5
5
 
6
- from iatoolkit.repositories.models import UserFeedback
6
+ from iatoolkit.repositories.models import UserFeedback, Company
7
7
  from injector import inject
8
8
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
9
  from iatoolkit.infra.google_chat_app import GoogleChatApp
10
+ from iatoolkit.infra.mail_app import MailApp # <-- 1. Importar MailApp
10
11
  import logging
11
12
 
12
13
 
13
14
  class UserFeedbackService:
14
15
  @inject
15
- def __init__(self, profile_repo: ProfileRepo, google_chat_app: GoogleChatApp):
16
+ def __init__(self,
17
+ profile_repo: ProfileRepo,
18
+ google_chat_app: GoogleChatApp,
19
+ mail_app: MailApp):
16
20
  self.profile_repo = profile_repo
17
21
  self.google_chat_app = google_chat_app
22
+ self.mail_app = mail_app
23
+
24
+ def _send_google_chat_notification(self, space_name: str, message_text: str):
25
+ """Envía una notificación de feedback a un espacio de Google Chat."""
26
+ try:
27
+ chat_data = {
28
+ "type": "MESSAGE_TRIGGER",
29
+ "space": {"name": space_name},
30
+ "message": {"text": message_text}
31
+ }
32
+ chat_result = self.google_chat_app.send_message(message_data=chat_data)
33
+ if not chat_result.get('success'):
34
+ logging.warning(f"Error al enviar notificación a Google Chat: {chat_result.get('message')}")
35
+ except Exception as e:
36
+ logging.exception(f"Fallo inesperado al enviar notificación a Google Chat: {e}")
37
+
38
+ def _send_email_notification(self, destination_email: str, company_name: str, message_text: str):
39
+ """Envía una notificación de feedback por correo electrónico."""
40
+ try:
41
+ subject = f"Nuevo Feedback de {company_name}"
42
+ # Convertir el texto plano a un HTML simple para mantener los saltos de línea
43
+ html_body = message_text.replace('\n', '<br>')
44
+ self.mail_app.send_email(to=destination_email, subject=subject, body=html_body)
45
+ except Exception as e:
46
+ logging.exception(f"Fallo inesperado al enviar email de feedback: {e}")
47
+
48
+ def _handle_notification(self, company: Company, message_text: str):
49
+ """Lee la configuración de la empresa y envía la notificación al canal correspondiente."""
50
+ feedback_params = company.parameters.get('user_feedback')
51
+ if not isinstance(feedback_params, dict):
52
+ logging.warning(f"No se encontró configuración de 'user_feedback' para la empresa {company.short_name}.")
53
+ return
54
+
55
+ # get channel and destination
56
+ channel = feedback_params.get('channel')
57
+ destination = feedback_params.get('destination')
58
+ if not channel or not destination:
59
+ logging.warning(f"Configuración 'user_feedback' incompleta para {company.short_name}. Faltan 'channel' o 'destination'.")
60
+ return
61
+
62
+ if channel == 'google_chat':
63
+ self._send_google_chat_notification(space_name=destination, message_text=message_text)
64
+ elif channel == 'email':
65
+ self._send_email_notification(destination_email=destination, company_name=company.short_name, message_text=message_text)
66
+ else:
67
+ logging.warning(f"Canal de feedback '{channel}' no reconocido para la empresa {company.short_name}.")
18
68
 
19
69
  def new_feedback(self,
20
70
  company_short_name: str,
21
71
  message: str,
22
- external_user_id: str = None,
23
- local_user_id: int = 0,
24
- space: str = None,
25
- type: str = None,
72
+ user_identifier: str,
26
73
  rating: int = None) -> dict:
27
74
  try:
28
- # validate company
75
+ # 1. Validar empresa
29
76
  company = self.profile_repo.get_company_by_short_name(company_short_name)
30
77
  if not company:
31
78
  return {'error': f'No existe la empresa: {company_short_name}'}
32
79
 
33
- # send notification to Google Chat
34
- chat_message = f"*Nuevo feedback de {company_short_name}*:\n*Usuario:* {external_user_id or local_user_id}\n*Mensaje:* {message}\n*Calificación:* {rating}"
35
-
36
- # TO DO: get the space and type from the input data
37
- chat_data = {
38
- "type": type,
39
- "space": {
40
- "name": space
41
- },
42
- "message": {
43
- "text": chat_message
44
- }
45
- }
46
-
47
- chat_result = self.google_chat_app.send_message(message_data=chat_data)
48
-
49
- if not chat_result.get('success'):
50
- logging.warning(f"Error al enviar notificación a Google Chat: {chat_result.get('message')}")
80
+ # 2. Enviar notificación según la configuración de la empresa
81
+ notification_text = (f"*Nuevo feedback de {company_short_name}*:\n"
82
+ f"*Usuario:* {user_identifier}\n"
83
+ f"*Mensaje:* {message}\n"
84
+ f"*Calificación:* {rating if rating is not None else 'N/A'}")
85
+ self._handle_notification(company, notification_text)
51
86
 
52
- # create the UserFeedback object
53
- new_feedback = UserFeedback(
87
+ # 3. Guardar el feedback en la base de datos (independientemente del éxito de la notificación)
88
+ new_feedback_obj = UserFeedback(
54
89
  company_id=company.id,
55
90
  message=message,
56
- local_user_id=local_user_id,
57
- external_user_id=external_user_id,
91
+ user_identifier=user_identifier,
58
92
  rating=rating
59
93
  )
60
- new_feedback = self.profile_repo.save_feedback(new_feedback)
61
- if not new_feedback:
94
+ saved_feedback = self.profile_repo.save_feedback(new_feedback_obj)
95
+ 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}")
62
97
  return {'error': 'No se pudo guardar el feedback'}
63
98
 
64
- return {'message': 'Feedback guardado correctamente'}
99
+ return {'success': True, 'message': 'Feedback guardado correctamente'}
65
100
 
66
101
  except Exception as e:
102
+ logging.exception(f"Error crítico en el servicio de feedback: {e}")
67
103
  return {'error': str(e)}
@@ -6,80 +6,138 @@
6
6
  from iatoolkit.infra.redis_session_manager import RedisSessionManager
7
7
  from typing import List, Dict, Optional
8
8
  import json
9
+ import logging
9
10
 
10
11
 
11
12
  class UserSessionContextService:
12
13
  """
13
- Gestiona el contexto de la sesión del usuario, incluyendo el historial
14
- de conversación con el LLM y datos de la sesión del usuario.
15
-
16
- Usa RedisSessionManager para persistencia directa en Redis.
14
+ Gestiona el contexto de la sesión del usuario usando un único Hash de Redis por sesión.
15
+ Esto mejora la atomicidad y la eficiencia.
17
16
  """
18
17
 
19
- def _get_llm_history_key(self, company_short_name: str, user_identifier: str) -> str:
20
- user_identifier = (user_identifier or "").strip()
21
- if not user_identifier:
22
- return None
23
- return f"llm_history:{company_short_name}/{user_identifier}"
24
-
25
- def _get_user_data_key(self, company_short_name: str, user_identifier: str) -> str:
18
+ def _get_session_key(self, company_short_name: str, user_identifier: str) -> Optional[str]:
19
+ """Devuelve la clave única de Redis para el Hash de sesión del usuario."""
26
20
  user_identifier = (user_identifier or "").strip()
27
- if not user_identifier:
21
+ if not company_short_name or not user_identifier:
28
22
  return None
29
- return f"user_data:{company_short_name}/{user_identifier}"
23
+ return f"session:{company_short_name}/{user_identifier}"
30
24
 
31
25
  def clear_all_context(self, company_short_name: str, user_identifier: str):
32
- """Limpia todo el contexto de sesión para un usuario."""
33
- self.clear_llm_history(company_short_name, user_identifier)
34
- self.clear_user_session_data(company_short_name, user_identifier)
26
+ """Limpia el contexto del LLM en la sesión para un usuario de forma atómica."""
27
+ session_key = self._get_session_key(company_short_name, user_identifier)
28
+ if session_key:
29
+ # RedisSessionManager.remove(session_key)
30
+ # 'profile_data' should not be deleted
31
+ RedisSessionManager.hdel(session_key, 'context_version')
32
+ RedisSessionManager.hdel(session_key, 'context_history')
33
+ RedisSessionManager.hdel(session_key, 'last_response_id')
35
34
 
36
35
  def clear_llm_history(self, company_short_name: str, user_identifier: str):
37
- history_key = self._get_llm_history_key(company_short_name, user_identifier)
38
- if history_key:
39
- RedisSessionManager.remove(history_key)
40
-
41
- def get_last_response_id(self, company_short_name: str, user_identifier: str) -> str:
42
- history_key = self._get_llm_history_key(company_short_name, user_identifier)
43
- if not history_key:
36
+ """Limpia solo los campos relacionados con el historial del LLM (ID y chat)."""
37
+ session_key = self._get_session_key(company_short_name, user_identifier)
38
+ if session_key:
39
+ RedisSessionManager.hdel(session_key, 'last_response_id', 'context_history')
40
+
41
+ def get_last_response_id(self, company_short_name: str, user_identifier: str) -> Optional[str]:
42
+ session_key = self._get_session_key(company_short_name, user_identifier)
43
+ if not session_key:
44
44
  return None
45
-
46
- return RedisSessionManager.get(history_key, '')
45
+ return RedisSessionManager.hget(session_key, 'last_response_id')
47
46
 
48
47
  def save_last_response_id(self, company_short_name: str, user_identifier: str, response_id: str):
49
- user_identifier = (user_identifier or "").strip()
50
- history_key = self._get_llm_history_key(company_short_name, user_identifier)
51
- if not history_key or not user_identifier:
52
- return
53
-
54
- RedisSessionManager.set(history_key, response_id)
48
+ session_key = self._get_session_key(company_short_name, user_identifier)
49
+ if session_key:
50
+ RedisSessionManager.hset(session_key, 'last_response_id', response_id)
55
51
 
56
52
  def save_context_history(self, company_short_name: str, user_identifier: str, context_history: List[Dict]):
57
- history_key = f"chat_history:{company_short_name}/{user_identifier}"
58
- if not history_key:
59
- return
60
- RedisSessionManager.set(history_key, json.dumps(context_history))
53
+ session_key = self._get_session_key(company_short_name, user_identifier)
54
+ if session_key:
55
+ try:
56
+ history_json = json.dumps(context_history)
57
+ RedisSessionManager.hset(session_key, 'context_history', history_json)
58
+ except (TypeError, ValueError) as e:
59
+ logging.error(f"Error al serializar context_history para {session_key}: {e}")
61
60
 
62
61
  def get_context_history(self, company_short_name: str, user_identifier: str) -> Optional[List[Dict]]:
63
- history_key = f"chat_history:{company_short_name}/{user_identifier}"
64
- return RedisSessionManager.get_json(history_key, {})
62
+ session_key = self._get_session_key(company_short_name, user_identifier)
63
+ if not session_key:
64
+ return None
65
65
 
66
- def save_user_session_data(self, company_short_name: str, user_identifier: str, data: dict):
67
- """Guarda un diccionario de datos en la sesión del usuario."""
68
- user_identifier = (user_identifier or "").strip()
69
- data_key = self._get_user_data_key(company_short_name, user_identifier)
70
- if data_key:
71
- RedisSessionManager.set_json(data_key, data)
72
-
73
- def get_user_session_data(self, company_short_name: str, user_identifier: str) -> dict:
74
- """Recupera el diccionario de datos de la sesión del usuario."""
75
- data_key = self._get_user_data_key(company_short_name, user_identifier)
76
- if not data_key:
66
+ history_json = RedisSessionManager.hget(session_key, 'context_history')
67
+ if not history_json:
68
+ return []
69
+
70
+ try:
71
+ return json.loads(history_json)
72
+ except json.JSONDecodeError:
73
+ return []
74
+
75
+ def save_profile_data(self, company_short_name: str, user_identifier: str, data: dict):
76
+ session_key = self._get_session_key(company_short_name, user_identifier)
77
+ if session_key:
78
+ try:
79
+ data_json = json.dumps(data)
80
+ RedisSessionManager.hset(session_key, 'profile_data', data_json)
81
+ except (TypeError, ValueError) as e:
82
+ logging.error(f"Error al serializar profile_data para {session_key}: {e}")
83
+
84
+ def get_profile_data(self, company_short_name: str, user_identifier: str) -> dict:
85
+ session_key = self._get_session_key(company_short_name, user_identifier)
86
+ if not session_key:
77
87
  return {}
78
88
 
79
- return RedisSessionManager.get_json(data_key, {})
89
+ data_json = RedisSessionManager.hget(session_key, 'profile_data')
90
+ if not data_json:
91
+ return {}
80
92
 
81
- def clear_user_session_data(self, company_short_name: str, user_identifier: str):
82
- """Limpia los datos de la sesión del usuario."""
83
- data_key = self._get_user_data_key(company_short_name, user_identifier)
84
- if data_key:
85
- RedisSessionManager.remove(data_key)
93
+ try:
94
+ return json.loads(data_json)
95
+ except json.JSONDecodeError:
96
+ return {}
97
+
98
+ def save_context_version(self, company_short_name: str, user_identifier: str, version: str):
99
+ session_key = self._get_session_key(company_short_name, user_identifier)
100
+ if session_key:
101
+ RedisSessionManager.hset(session_key, 'context_version', version)
102
+
103
+ def get_context_version(self, company_short_name: str, user_identifier: str) -> Optional[str]:
104
+ session_key = self._get_session_key(company_short_name, user_identifier)
105
+ if not session_key:
106
+ return None
107
+ return RedisSessionManager.hget(session_key, 'context_version')
108
+
109
+ def save_prepared_context(self, company_short_name: str, user_identifier: str, context: str, version: str):
110
+ """Guarda un contexto de sistema pre-renderizado y su versión, listos para ser enviados al LLM."""
111
+ session_key = self._get_session_key(company_short_name, user_identifier)
112
+ if session_key:
113
+ RedisSessionManager.hset(session_key, 'prepared_context', context)
114
+ RedisSessionManager.hset(session_key, 'prepared_context_version', version)
115
+
116
+ def get_and_clear_prepared_context(self, company_short_name: str, user_identifier: str) -> tuple:
117
+ """Obtiene el contexto preparado y su versión, y los elimina para asegurar que se usan una sola vez."""
118
+ session_key = self._get_session_key(company_short_name, user_identifier)
119
+ if not session_key:
120
+ return None, None
121
+
122
+ pipe = RedisSessionManager.pipeline()
123
+ pipe.hget(session_key, 'prepared_context')
124
+ pipe.hget(session_key, 'prepared_context_version')
125
+ pipe.hdel(session_key, 'prepared_context', 'prepared_context_version')
126
+ results = pipe.execute()
127
+
128
+ # results[0] es el contexto, results[1] es la versión
129
+ return (results[0], results[1]) if results else (None, None)
130
+
131
+ # --- Métodos de Bloqueo ---
132
+ def acquire_lock(self, lock_key: str, expire_seconds: int) -> bool:
133
+ """Intenta adquirir un lock. Devuelve True si se adquiere, False si no."""
134
+ # SET con NX (solo si no existe) y EX (expiración) es una operación atómica.
135
+ return RedisSessionManager.set(lock_key, "1", ex=expire_seconds, nx=True)
136
+
137
+ def release_lock(self, lock_key: str):
138
+ """Libera un lock."""
139
+ RedisSessionManager.remove(lock_key)
140
+
141
+ def is_locked(self, lock_key: str) -> bool:
142
+ """Verifica si un lock existe."""
143
+ return RedisSessionManager.exists(lock_key)
Binary file