iatoolkit 0.8.1__py3-none-any.whl → 0.63.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.
Potentially problematic release.
This version of iatoolkit might be problematic. Click here for more details.
- iatoolkit/__init__.py +8 -34
- iatoolkit/base_company.py +14 -3
- iatoolkit/common/routes.py +83 -52
- iatoolkit/common/session_manager.py +0 -1
- iatoolkit/common/util.py +0 -27
- iatoolkit/iatoolkit.py +61 -46
- iatoolkit/infra/llm_client.py +7 -8
- iatoolkit/infra/openai_adapter.py +1 -1
- iatoolkit/infra/redis_session_manager.py +48 -2
- iatoolkit/repositories/database_manager.py +17 -2
- iatoolkit/repositories/models.py +31 -6
- iatoolkit/repositories/profile_repo.py +7 -2
- iatoolkit/services/auth_service.py +188 -0
- iatoolkit/services/branding_service.py +147 -0
- iatoolkit/services/dispatcher_service.py +10 -40
- iatoolkit/services/excel_service.py +15 -15
- iatoolkit/services/history_service.py +3 -12
- iatoolkit/services/jwt_service.py +15 -24
- iatoolkit/services/onboarding_service.py +43 -0
- iatoolkit/services/profile_service.py +97 -44
- iatoolkit/services/query_service.py +124 -81
- iatoolkit/services/tasks_service.py +1 -1
- iatoolkit/services/user_feedback_service.py +67 -31
- iatoolkit/services/user_session_context_service.py +112 -54
- iatoolkit/static/images/fernando.jpeg +0 -0
- iatoolkit/static/js/{chat_feedback.js → chat_feedback_button.js} +6 -11
- iatoolkit/static/js/chat_history_button.js +126 -0
- iatoolkit/static/js/chat_logout_button.js +36 -0
- iatoolkit/static/js/chat_main.js +130 -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 +52 -0
- iatoolkit/static/styles/chat_iatoolkit.css +329 -507
- iatoolkit/static/styles/chat_modal.css +95 -56
- 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 +40 -0
- iatoolkit/templates/base.html +8 -3
- iatoolkit/templates/change_password.html +54 -37
- iatoolkit/templates/chat.html +149 -66
- iatoolkit/templates/chat_modals.html +47 -18
- iatoolkit/templates/error.html +41 -8
- iatoolkit/templates/forgot_password.html +37 -24
- iatoolkit/templates/index.html +140 -0
- iatoolkit/templates/login_simulation.html +34 -0
- iatoolkit/templates/onboarding_shell.html +105 -0
- iatoolkit/templates/signup.html +64 -66
- iatoolkit/views/base_login_view.py +81 -0
- iatoolkit/views/change_password_view.py +23 -12
- 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 +23 -13
- iatoolkit/views/history_api_view.py +52 -0
- iatoolkit/views/home_view.py +58 -25
- iatoolkit/views/index_view.py +14 -0
- iatoolkit/views/init_context_api_view.py +68 -0
- iatoolkit/views/llmquery_api_view.py +45 -0
- iatoolkit/views/login_simulation_view.py +81 -0
- iatoolkit/views/login_view.py +118 -34
- iatoolkit/views/logout_api_view.py +45 -0
- iatoolkit/views/{prompt_view.py → prompt_api_view.py} +7 -7
- iatoolkit/views/signup_view.py +38 -29
- iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
- iatoolkit/views/tasks_review_api_view.py +55 -0
- iatoolkit/views/{user_feedback_view.py → user_feedback_api_view.py} +16 -31
- iatoolkit/views/verify_user_view.py +13 -8
- {iatoolkit-0.8.1.dist-info → iatoolkit-0.63.4.dist-info}/METADATA +2 -2
- iatoolkit-0.63.4.dist-info/RECORD +113 -0
- {iatoolkit-0.8.1.dist-info → iatoolkit-0.63.4.dist-info}/top_level.txt +0 -1
- 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_history.js +0 -117
- iatoolkit/templates/home.html +0 -201
- iatoolkit/templates/login.html +0 -43
- iatoolkit/views/chat_token_request_view.py +0 -98
- iatoolkit/views/chat_view.py +0 -51
- iatoolkit/views/download_file_view.py +0 -58
- iatoolkit/views/external_chat_login_view.py +0 -88
- iatoolkit/views/history_view.py +0 -57
- iatoolkit/views/llmquery_view.py +0 -65
- iatoolkit/views/tasks_review_view.py +0 -83
- iatoolkit-0.8.1.dist-info/RECORD +0 -175
- tests/__init__.py +0 -5
- tests/common/__init__.py +0 -0
- tests/common/test_auth.py +0 -279
- tests/common/test_routes.py +0 -42
- tests/common/test_session_manager.py +0 -59
- tests/common/test_util.py +0 -444
- tests/companies/__init__.py +0 -5
- tests/conftest.py +0 -36
- tests/infra/__init__.py +0 -5
- tests/infra/connectors/__init__.py +0 -5
- tests/infra/connectors/test_google_drive_connector.py +0 -107
- tests/infra/connectors/test_local_file_connector.py +0 -85
- tests/infra/connectors/test_s3_connector.py +0 -95
- tests/infra/test_call_service.py +0 -92
- tests/infra/test_database_manager.py +0 -59
- tests/infra/test_gemini_adapter.py +0 -137
- tests/infra/test_google_chat_app.py +0 -68
- tests/infra/test_llm_client.py +0 -165
- tests/infra/test_llm_proxy.py +0 -122
- tests/infra/test_mail_app.py +0 -94
- tests/infra/test_openai_adapter.py +0 -105
- tests/infra/test_redis_session_manager_service.py +0 -117
- tests/repositories/__init__.py +0 -5
- tests/repositories/test_database_manager.py +0 -87
- tests/repositories/test_document_repo.py +0 -76
- tests/repositories/test_llm_query_repo.py +0 -340
- tests/repositories/test_models.py +0 -38
- tests/repositories/test_profile_repo.py +0 -142
- tests/repositories/test_tasks_repo.py +0 -76
- tests/repositories/test_vs_repo.py +0 -107
- tests/services/__init__.py +0 -5
- tests/services/test_dispatcher_service.py +0 -274
- tests/services/test_document_service.py +0 -181
- tests/services/test_excel_service.py +0 -208
- tests/services/test_file_processor_service.py +0 -121
- tests/services/test_history_service.py +0 -164
- tests/services/test_jwt_service.py +0 -255
- tests/services/test_load_documents_service.py +0 -112
- tests/services/test_mail_service.py +0 -70
- tests/services/test_profile_service.py +0 -379
- tests/services/test_prompt_manager_service.py +0 -190
- tests/services/test_query_service.py +0 -243
- tests/services/test_search_service.py +0 -39
- tests/services/test_sql_service.py +0 -160
- tests/services/test_tasks_service.py +0 -252
- tests/services/test_user_feedback_service.py +0 -389
- tests/services/test_user_session_context_service.py +0 -132
- tests/views/__init__.py +0 -5
- tests/views/test_change_password_view.py +0 -191
- tests/views/test_chat_token_request_view.py +0 -188
- tests/views/test_chat_view.py +0 -98
- tests/views/test_download_file_view.py +0 -149
- tests/views/test_external_chat_login_view.py +0 -120
- tests/views/test_external_login_view.py +0 -102
- tests/views/test_file_store_view.py +0 -128
- tests/views/test_forgot_password_view.py +0 -142
- tests/views/test_history_view.py +0 -336
- tests/views/test_home_view.py +0 -61
- tests/views/test_llm_query_view.py +0 -154
- tests/views/test_login_view.py +0 -114
- tests/views/test_prompt_view.py +0 -111
- tests/views/test_signup_view.py +0 -140
- tests/views/test_tasks_review_view.py +0 -104
- tests/views/test_tasks_view.py +0 -130
- tests/views/test_user_feedback_view.py +0 -214
- tests/views/test_verify_user_view.py +0 -110
- {iatoolkit-0.8.1.dist-info → iatoolkit-0.63.4.dist-info}/WHEEL +0 -0
|
@@ -24,16 +24,18 @@ class JWTService:
|
|
|
24
24
|
raise RuntimeError(f"Configuración JWT esencial faltante: {e}")
|
|
25
25
|
|
|
26
26
|
def generate_chat_jwt(self,
|
|
27
|
-
company_id: int,
|
|
28
27
|
company_short_name: str,
|
|
29
|
-
|
|
28
|
+
user_identifier: str,
|
|
30
29
|
expires_delta_seconds: int) -> Optional[str]:
|
|
31
30
|
# generate a JWT for a chat session
|
|
32
31
|
try:
|
|
32
|
+
if not company_short_name or not user_identifier:
|
|
33
|
+
logging.error(f"Missing token ID: {company_short_name}/{user_identifier}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
33
36
|
payload = {
|
|
34
|
-
'company_id': company_id,
|
|
35
37
|
'company_short_name': company_short_name,
|
|
36
|
-
'
|
|
38
|
+
'user_identifier': user_identifier,
|
|
37
39
|
'exp': time.time() + expires_delta_seconds,
|
|
38
40
|
'iat': time.time(),
|
|
39
41
|
'type': 'chat_session' # Identificador del tipo de token
|
|
@@ -41,10 +43,10 @@ class JWTService:
|
|
|
41
43
|
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
|
42
44
|
return token
|
|
43
45
|
except Exception as e:
|
|
44
|
-
logging.error(f"Error al generar JWT para
|
|
46
|
+
logging.error(f"Error al generar JWT para {company_short_name}/{user_identifier}: {e}")
|
|
45
47
|
return None
|
|
46
48
|
|
|
47
|
-
def validate_chat_jwt(self, token: str
|
|
49
|
+
def validate_chat_jwt(self, token: str) -> Optional[Dict[str, Any]]:
|
|
48
50
|
"""
|
|
49
51
|
Valida un JWT de sesión de chat.
|
|
50
52
|
Retorna el payload decodificado si es válido y coincide con la empresa, o None.
|
|
@@ -59,33 +61,22 @@ class JWTService:
|
|
|
59
61
|
logging.warning(f"Validación JWT fallida: tipo incorrecto '{payload.get('type')}'")
|
|
60
62
|
return None
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
f"Esperado: {expected_company_short_name}, Obtenido: {payload.get('company_short_name')}"
|
|
66
|
-
)
|
|
64
|
+
# user_identifier debe estar presente
|
|
65
|
+
if not payload.get('user_identifier'):
|
|
66
|
+
logging.warning(f"Validación JWT fallida: user_identifier ausente o vacío.")
|
|
67
67
|
return None
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
logging.warning(f"Validación JWT fallida: external_user_id ausente o vacío.")
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
# company_id debe estar presente
|
|
75
|
-
if 'company_id' not in payload or not isinstance(payload['company_id'], int):
|
|
76
|
-
logging.warning(f"Validación JWT fallida: company_id ausente o tipo incorrecto.")
|
|
69
|
+
if not payload.get('company_short_name'):
|
|
70
|
+
logging.warning(f"Validación JWT fallida: company_short_name ausente.")
|
|
77
71
|
return None
|
|
78
72
|
|
|
79
73
|
logging.debug(
|
|
80
74
|
f"JWT validado exitosamente para company: {payload.get('company_short_name')}, user: {payload.get('external_user_id')}")
|
|
81
75
|
return payload
|
|
82
76
|
|
|
83
|
-
except jwt.ExpiredSignatureError:
|
|
84
|
-
logging.info(f"Validación JWT fallida: token expirado para {expected_company_short_name}")
|
|
85
|
-
return None
|
|
86
77
|
except jwt.InvalidTokenError as e:
|
|
87
|
-
logging.warning(f"Validación JWT fallida: token inválido
|
|
78
|
+
logging.warning(f"Validación JWT fallida: token inválido . Error: {e}")
|
|
88
79
|
return None
|
|
89
80
|
except Exception as e:
|
|
90
|
-
logging.error(f"Error inesperado durante validación de JWT
|
|
81
|
+
logging.error(f"Error inesperado durante validación de JWT : {e}")
|
|
91
82
|
return None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
from iatoolkit.repositories.models import Company
|
|
7
|
+
from typing import List, Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OnboardingService:
|
|
11
|
+
"""
|
|
12
|
+
Servicio para gestionar las tarjetas de contenido que se muestran
|
|
13
|
+
durante la pantalla de carga (onboarding).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
"""
|
|
18
|
+
Define el conjunto de tarjetas de onboarding por defecto.
|
|
19
|
+
"""
|
|
20
|
+
self._default_cards = [
|
|
21
|
+
{'icon': 'fas fa-users', 'title': 'Clientes',
|
|
22
|
+
'text': 'Conozco en detalle a nuestros clientes: antigüedad, contactos, historial de operaciones.<br><br><strong>Ejemplo:</strong> ¿cuántos clientes nuevos se incorporaron a mi cartera este año?'},
|
|
23
|
+
{'icon': 'fas fa-cubes', 'title': 'Productos',
|
|
24
|
+
'text': 'Productos: características, condiciones, historial.'},
|
|
25
|
+
|
|
26
|
+
{'icon': 'fas fa-cogs', 'title': 'Personaliza tus Prompts',
|
|
27
|
+
'text': 'Utiliza la varita mágica y podrás explorar los prompts predefinidos que he preparado para ti.'},
|
|
28
|
+
{'icon': 'fas fa-table', 'title': 'Tablas y Excel',
|
|
29
|
+
'text': 'Puedes pedirme la respuesta en formato de tablas o excel.<br><br><strong>Ejemplo:</strong> dame una tabla con los 10 certificados más grandes este año.'},
|
|
30
|
+
{'icon': 'fas fa-shield-alt', 'title': 'Seguridad y Confidencialidad',
|
|
31
|
+
'text': 'Toda tu información es procesada de forma segura y confidencial dentro de nuestro entorno protegido.'}
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def get_onboarding_cards(self, company: Company | None) -> List[Dict[str, Any]]:
|
|
35
|
+
"""
|
|
36
|
+
Retorna la lista de tarjetas de onboarding para una compañía.
|
|
37
|
+
Si la compañía tiene tarjetas personalizadas, las devuelve.
|
|
38
|
+
De lo contrario, devuelve las tarjetas por defecto.
|
|
39
|
+
"""
|
|
40
|
+
if company and company.onboarding_cards:
|
|
41
|
+
return company.onboarding_cards
|
|
42
|
+
|
|
43
|
+
return self._default_cards
|
|
@@ -5,19 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
from injector import inject
|
|
7
7
|
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
8
|
+
from iatoolkit.services.dispatcher_service import Dispatcher
|
|
8
9
|
from iatoolkit.repositories.models import User, Company, ApiKey
|
|
9
10
|
from flask_bcrypt import check_password_hash
|
|
10
11
|
from iatoolkit.common.session_manager import SessionManager
|
|
12
|
+
from iatoolkit.services.user_session_context_service import UserSessionContextService
|
|
11
13
|
from flask_bcrypt import Bcrypt
|
|
12
14
|
from iatoolkit.infra.mail_app import MailApp
|
|
13
15
|
import random
|
|
14
|
-
import logging
|
|
15
16
|
import re
|
|
16
17
|
import secrets
|
|
17
18
|
import string
|
|
18
|
-
from
|
|
19
|
-
from iatoolkit.services.user_session_context_service import UserSessionContextService
|
|
20
|
-
from iatoolkit.services.query_service import QueryService
|
|
19
|
+
from iatoolkit.services.dispatcher_service import Dispatcher
|
|
21
20
|
|
|
22
21
|
|
|
23
22
|
class ProfileService:
|
|
@@ -25,71 +24,122 @@ class ProfileService:
|
|
|
25
24
|
def __init__(self,
|
|
26
25
|
profile_repo: ProfileRepo,
|
|
27
26
|
session_context_service: UserSessionContextService,
|
|
28
|
-
|
|
27
|
+
dispatcher: Dispatcher,
|
|
29
28
|
mail_app: MailApp):
|
|
30
29
|
self.profile_repo = profile_repo
|
|
30
|
+
self.dispatcher = dispatcher
|
|
31
31
|
self.session_context = session_context_service
|
|
32
|
-
self.query_service = query_service
|
|
33
32
|
self.mail_app = mail_app
|
|
34
33
|
self.bcrypt = Bcrypt()
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
def login(self, company_short_name: str, email: str, password: str) -> dict:
|
|
38
37
|
try:
|
|
39
|
-
# check if
|
|
38
|
+
# check if user exists
|
|
40
39
|
user = self.profile_repo.get_user_by_email(email)
|
|
41
40
|
if not user:
|
|
42
|
-
return {"
|
|
41
|
+
return {'success': False, "message": "Usuario no encontrado"}
|
|
43
42
|
|
|
44
43
|
# check the encrypted password
|
|
45
44
|
if not check_password_hash(user.password, password):
|
|
46
|
-
return {"
|
|
45
|
+
return {'success': False, "message": "Contraseña inválida"}
|
|
47
46
|
|
|
48
|
-
company = self.get_company_by_short_name(company_short_name)
|
|
47
|
+
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
49
48
|
if not company:
|
|
50
|
-
return {"
|
|
49
|
+
return {'success': False, "message": "Empresa no encontrada"}
|
|
51
50
|
|
|
52
|
-
# check that user belongs to
|
|
51
|
+
# check that user belongs to company
|
|
53
52
|
if company not in user.companies:
|
|
54
|
-
return {"
|
|
53
|
+
return {'success': False, "message": "Usuario no esta autorizado para esta empresa"}
|
|
55
54
|
|
|
56
55
|
if not user.verified:
|
|
57
|
-
return {
|
|
56
|
+
return {'success': False,
|
|
57
|
+
"message": "Tu cuenta no ha sido verificada. Por favor, revisa tu correo."}
|
|
58
|
+
|
|
59
|
+
# 1. Build the local user profile dictionary here.
|
|
60
|
+
# the user_profile variables are used on the LLM templates also (see in query_main.prompt)
|
|
61
|
+
user_identifier = user.email # no longer de ID
|
|
62
|
+
user_profile = {
|
|
63
|
+
"user_email": user.email,
|
|
64
|
+
"user_fullname": f'{user.first_name} {user.last_name}',
|
|
65
|
+
"user_is_local": True,
|
|
66
|
+
"extras": {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# 2. create user_profile in context
|
|
70
|
+
self.save_user_profile(company, user_identifier, user_profile)
|
|
71
|
+
|
|
72
|
+
# 3. create the web session
|
|
73
|
+
self.set_session_for_user(company.short_name, user_identifier)
|
|
74
|
+
return {'success': True, "user_identifier": user_identifier, "message": "Login exitoso"}
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return {'success': False, "message": str(e)}
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
def create_external_user_profile_context(self, company: Company, user_identifier: str):
|
|
79
|
+
"""
|
|
80
|
+
Public method for views to create a user profile context for an external user.
|
|
81
|
+
"""
|
|
82
|
+
# 1. Fetch the external user profile via Dispatcher.
|
|
83
|
+
external_user_profile = self.dispatcher.get_user_info(
|
|
84
|
+
company_name=company.short_name,
|
|
85
|
+
user_identifier=user_identifier
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 2. Call the session creation helper with external_user_id as user_identifier
|
|
89
|
+
self.save_user_profile(
|
|
90
|
+
company=company,
|
|
91
|
+
user_identifier=user_identifier,
|
|
92
|
+
user_profile=external_user_profile)
|
|
93
|
+
|
|
94
|
+
def save_user_profile(self, company: Company, user_identifier: str, user_profile: dict):
|
|
95
|
+
"""
|
|
96
|
+
Private helper: Takes a pre-built profile, saves it to Redis, and sets the Flask cookie.
|
|
97
|
+
"""
|
|
98
|
+
user_profile['company_short_name'] = company.short_name
|
|
99
|
+
user_profile['user_identifier'] = user_identifier
|
|
100
|
+
user_profile['id'] = user_identifier
|
|
101
|
+
user_profile['company_id'] = company.id
|
|
102
|
+
user_profile['company'] = company.name
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
company_short_name=company_short_name,
|
|
65
|
-
local_user_id=user.id
|
|
66
|
-
)
|
|
104
|
+
# save user_profile in Redis session
|
|
105
|
+
self.session_context.save_profile_data(company.short_name, user_identifier, user_profile)
|
|
67
106
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
def set_session_for_user(self, company_short_name: str, user_identifier:str ):
|
|
108
|
+
# save a min Flask session cookie for this user
|
|
109
|
+
SessionManager.set('company_short_name', company_short_name)
|
|
110
|
+
SessionManager.set('user_identifier', user_identifier)
|
|
72
111
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
def get_current_session_info(self) -> dict:
|
|
113
|
+
"""
|
|
114
|
+
Gets the current web user's profile from the unified session.
|
|
115
|
+
This is the standard way to access user data for web requests.
|
|
116
|
+
"""
|
|
117
|
+
# 1. Get identifiers from the simple Flask session cookie.
|
|
118
|
+
user_identifier = SessionManager.get('user_identifier')
|
|
119
|
+
company_short_name = SessionManager.get('company_short_name')
|
|
120
|
+
|
|
121
|
+
if not user_identifier or not company_short_name:
|
|
122
|
+
# No authenticated web user.
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
# 2. Use the identifiers to fetch the full, authoritative profile from Redis.
|
|
126
|
+
profile = self.session_context.get_profile_data(company_short_name, user_identifier)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"user_identifier": user_identifier,
|
|
130
|
+
"company_short_name": company_short_name,
|
|
131
|
+
"profile": profile
|
|
88
132
|
}
|
|
89
|
-
SessionManager.set('user', user_data)
|
|
90
133
|
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
|
|
135
|
+
def get_profile_by_identifier(self, company_short_name: str, user_identifier: str) -> dict:
|
|
136
|
+
"""
|
|
137
|
+
Fetches a user profile directly by their identifier, bypassing the Flask session.
|
|
138
|
+
This is ideal for API-side checks.
|
|
139
|
+
"""
|
|
140
|
+
if not company_short_name or not user_identifier:
|
|
141
|
+
return {}
|
|
142
|
+
return self.session_context.get_profile_data(company_short_name, user_identifier)
|
|
93
143
|
|
|
94
144
|
|
|
95
145
|
def signup(self,
|
|
@@ -103,7 +153,7 @@ class ProfileService:
|
|
|
103
153
|
try:
|
|
104
154
|
|
|
105
155
|
# get company info
|
|
106
|
-
company = self.get_company_by_short_name(company_short_name)
|
|
156
|
+
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
107
157
|
if not company:
|
|
108
158
|
return {"error": f"la empresa {company_short_name} no existe"}
|
|
109
159
|
|
|
@@ -248,6 +298,9 @@ class ProfileService:
|
|
|
248
298
|
def get_company_by_short_name(self, short_name: str) -> Company:
|
|
249
299
|
return self.profile_repo.get_company_by_short_name(short_name)
|
|
250
300
|
|
|
301
|
+
def get_active_api_key_entry(self, api_key_value: str) -> ApiKey | None:
|
|
302
|
+
return self.profile_repo.get_active_api_key_entry(api_key_value)
|
|
303
|
+
|
|
251
304
|
def new_api_key(self, company_short_name: str):
|
|
252
305
|
company = self.get_company_by_short_name(company_short_name)
|
|
253
306
|
if not company:
|
|
@@ -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,
|