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.
- iatoolkit/base_company.py +11 -3
- iatoolkit/common/routes.py +92 -52
- iatoolkit/common/session_manager.py +0 -1
- iatoolkit/common/util.py +17 -27
- iatoolkit/iatoolkit.py +91 -47
- iatoolkit/infra/llm_client.py +7 -8
- iatoolkit/infra/openai_adapter.py +1 -1
- iatoolkit/infra/redis_session_manager.py +48 -2
- iatoolkit/locales/en.yaml +144 -0
- iatoolkit/locales/es.yaml +140 -0
- iatoolkit/repositories/database_manager.py +17 -2
- iatoolkit/repositories/models.py +31 -4
- iatoolkit/repositories/profile_repo.py +7 -2
- iatoolkit/services/auth_service.py +193 -0
- iatoolkit/services/branding_service.py +59 -18
- iatoolkit/services/dispatcher_service.py +10 -40
- iatoolkit/services/excel_service.py +15 -15
- iatoolkit/services/help_content_service.py +30 -0
- iatoolkit/services/history_service.py +2 -11
- iatoolkit/services/i18n_service.py +104 -0
- iatoolkit/services/jwt_service.py +15 -24
- iatoolkit/services/language_service.py +77 -0
- iatoolkit/services/onboarding_service.py +43 -0
- iatoolkit/services/profile_service.py +148 -75
- iatoolkit/services/query_service.py +124 -81
- iatoolkit/services/tasks_service.py +1 -1
- iatoolkit/services/user_feedback_service.py +68 -32
- iatoolkit/services/user_session_context_service.py +112 -54
- iatoolkit/static/images/fernando.jpeg +0 -0
- iatoolkit/static/js/chat_feedback_button.js +80 -0
- iatoolkit/static/js/chat_help_content.js +124 -0
- iatoolkit/static/js/chat_history_button.js +112 -0
- iatoolkit/static/js/chat_logout_button.js +36 -0
- iatoolkit/static/js/chat_main.js +148 -220
- iatoolkit/static/js/chat_onboarding_button.js +97 -0
- iatoolkit/static/js/chat_prompt_manager.js +94 -0
- iatoolkit/static/js/chat_reload_button.js +35 -0
- iatoolkit/static/styles/chat_iatoolkit.css +367 -199
- iatoolkit/static/styles/chat_modal.css +98 -76
- iatoolkit/static/styles/chat_public.css +107 -0
- iatoolkit/static/styles/landing_page.css +182 -0
- iatoolkit/static/styles/onboarding.css +169 -0
- iatoolkit/system_prompts/query_main.prompt +3 -12
- iatoolkit/templates/_company_header.html +20 -0
- iatoolkit/templates/_login_widget.html +42 -0
- iatoolkit/templates/base.html +40 -20
- iatoolkit/templates/change_password.html +57 -36
- iatoolkit/templates/chat.html +169 -83
- iatoolkit/templates/chat_modals.html +134 -68
- iatoolkit/templates/error.html +44 -8
- iatoolkit/templates/forgot_password.html +40 -23
- iatoolkit/templates/index.html +145 -0
- iatoolkit/templates/login_simulation.html +34 -0
- iatoolkit/templates/onboarding_shell.html +104 -0
- iatoolkit/templates/signup.html +63 -65
- iatoolkit/views/base_login_view.py +92 -0
- iatoolkit/views/change_password_view.py +56 -30
- iatoolkit/views/external_login_view.py +61 -28
- iatoolkit/views/{file_store_view.py → file_store_api_view.py} +9 -2
- iatoolkit/views/forgot_password_view.py +27 -19
- iatoolkit/views/help_content_api_view.py +54 -0
- iatoolkit/views/history_api_view.py +56 -0
- iatoolkit/views/home_view.py +50 -23
- iatoolkit/views/index_view.py +14 -0
- iatoolkit/views/init_context_api_view.py +73 -0
- iatoolkit/views/llmquery_api_view.py +57 -0
- iatoolkit/views/login_simulation_view.py +81 -0
- iatoolkit/views/login_view.py +130 -37
- iatoolkit/views/logout_api_view.py +49 -0
- iatoolkit/views/profile_api_view.py +46 -0
- iatoolkit/views/{prompt_view.py → prompt_api_view.py} +10 -10
- iatoolkit/views/signup_view.py +42 -35
- iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
- iatoolkit/views/tasks_review_api_view.py +55 -0
- iatoolkit/views/user_feedback_api_view.py +60 -0
- iatoolkit/views/verify_user_view.py +35 -28
- {iatoolkit-0.11.0.dist-info → iatoolkit-0.66.2.dist-info}/METADATA +2 -2
- iatoolkit-0.66.2.dist-info/RECORD +119 -0
- iatoolkit/common/auth.py +0 -200
- iatoolkit/static/images/arrow_up.png +0 -0
- iatoolkit/static/images/diagrama_iatoolkit.jpg +0 -0
- iatoolkit/static/images/logo_clinica.png +0 -0
- iatoolkit/static/images/logo_iatoolkit.png +0 -0
- iatoolkit/static/images/logo_maxxa.png +0 -0
- iatoolkit/static/images/logo_notaria.png +0 -0
- iatoolkit/static/images/logo_tarjeta.png +0 -0
- iatoolkit/static/images/logo_umayor.png +0 -0
- iatoolkit/static/images/upload.png +0 -0
- iatoolkit/static/js/chat_feedback.js +0 -115
- iatoolkit/static/js/chat_history.js +0 -117
- iatoolkit/static/styles/chat_info.css +0 -53
- iatoolkit/templates/header.html +0 -31
- iatoolkit/templates/home.html +0 -199
- iatoolkit/templates/login.html +0 -43
- iatoolkit/templates/test.html +0 -9
- iatoolkit/views/chat_token_request_view.py +0 -98
- iatoolkit/views/chat_view.py +0 -58
- iatoolkit/views/download_file_view.py +0 -58
- iatoolkit/views/external_chat_login_view.py +0 -95
- iatoolkit/views/history_view.py +0 -57
- iatoolkit/views/llmquery_view.py +0 -65
- iatoolkit/views/tasks_review_view.py +0 -83
- iatoolkit/views/user_feedback_view.py +0 -74
- iatoolkit-0.11.0.dist-info/RECORD +0 -110
- {iatoolkit-0.11.0.dist-info → iatoolkit-0.66.2.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
60
|
-
|
|
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
|
-
#
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
"No se pudo resolver el identificador del usuario")
|
|
92
|
+
return {'rebuild_needed': True, 'error': 'Invalid user identifier'}
|
|
73
93
|
|
|
74
|
-
company
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
#
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
190
|
-
final_client_data = (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
external_user_id=external_user_id,
|
|
91
|
+
user_identifier=user_identifier,
|
|
58
92
|
rating=rating
|
|
59
93
|
)
|
|
60
|
-
|
|
61
|
-
if not
|
|
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
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
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"
|
|
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
|
|
33
|
-
self.
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
62
|
+
session_key = self._get_session_key(company_short_name, user_identifier)
|
|
63
|
+
if not session_key:
|
|
64
|
+
return None
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
89
|
+
data_json = RedisSessionManager.hget(session_key, 'profile_data')
|
|
90
|
+
if not data_json:
|
|
91
|
+
return {}
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|