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.

Files changed (159) hide show
  1. iatoolkit/__init__.py +8 -34
  2. iatoolkit/base_company.py +14 -3
  3. iatoolkit/common/routes.py +83 -52
  4. iatoolkit/common/session_manager.py +0 -1
  5. iatoolkit/common/util.py +0 -27
  6. iatoolkit/iatoolkit.py +61 -46
  7. iatoolkit/infra/llm_client.py +7 -8
  8. iatoolkit/infra/openai_adapter.py +1 -1
  9. iatoolkit/infra/redis_session_manager.py +48 -2
  10. iatoolkit/repositories/database_manager.py +17 -2
  11. iatoolkit/repositories/models.py +31 -6
  12. iatoolkit/repositories/profile_repo.py +7 -2
  13. iatoolkit/services/auth_service.py +188 -0
  14. iatoolkit/services/branding_service.py +147 -0
  15. iatoolkit/services/dispatcher_service.py +10 -40
  16. iatoolkit/services/excel_service.py +15 -15
  17. iatoolkit/services/history_service.py +3 -12
  18. iatoolkit/services/jwt_service.py +15 -24
  19. iatoolkit/services/onboarding_service.py +43 -0
  20. iatoolkit/services/profile_service.py +97 -44
  21. iatoolkit/services/query_service.py +124 -81
  22. iatoolkit/services/tasks_service.py +1 -1
  23. iatoolkit/services/user_feedback_service.py +67 -31
  24. iatoolkit/services/user_session_context_service.py +112 -54
  25. iatoolkit/static/images/fernando.jpeg +0 -0
  26. iatoolkit/static/js/{chat_feedback.js → chat_feedback_button.js} +6 -11
  27. iatoolkit/static/js/chat_history_button.js +126 -0
  28. iatoolkit/static/js/chat_logout_button.js +36 -0
  29. iatoolkit/static/js/chat_main.js +130 -220
  30. iatoolkit/static/js/chat_onboarding_button.js +97 -0
  31. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  32. iatoolkit/static/js/chat_reload_button.js +52 -0
  33. iatoolkit/static/styles/chat_iatoolkit.css +329 -507
  34. iatoolkit/static/styles/chat_modal.css +95 -56
  35. iatoolkit/static/styles/landing_page.css +182 -0
  36. iatoolkit/static/styles/onboarding.css +169 -0
  37. iatoolkit/system_prompts/query_main.prompt +3 -12
  38. iatoolkit/templates/_company_header.html +20 -0
  39. iatoolkit/templates/_login_widget.html +40 -0
  40. iatoolkit/templates/base.html +8 -3
  41. iatoolkit/templates/change_password.html +54 -37
  42. iatoolkit/templates/chat.html +149 -66
  43. iatoolkit/templates/chat_modals.html +47 -18
  44. iatoolkit/templates/error.html +41 -8
  45. iatoolkit/templates/forgot_password.html +37 -24
  46. iatoolkit/templates/index.html +140 -0
  47. iatoolkit/templates/login_simulation.html +34 -0
  48. iatoolkit/templates/onboarding_shell.html +105 -0
  49. iatoolkit/templates/signup.html +64 -66
  50. iatoolkit/views/base_login_view.py +81 -0
  51. iatoolkit/views/change_password_view.py +23 -12
  52. iatoolkit/views/external_login_view.py +61 -28
  53. iatoolkit/views/{file_store_view.py → file_store_api_view.py} +9 -2
  54. iatoolkit/views/forgot_password_view.py +23 -13
  55. iatoolkit/views/history_api_view.py +52 -0
  56. iatoolkit/views/home_view.py +58 -25
  57. iatoolkit/views/index_view.py +14 -0
  58. iatoolkit/views/init_context_api_view.py +68 -0
  59. iatoolkit/views/llmquery_api_view.py +45 -0
  60. iatoolkit/views/login_simulation_view.py +81 -0
  61. iatoolkit/views/login_view.py +118 -34
  62. iatoolkit/views/logout_api_view.py +45 -0
  63. iatoolkit/views/{prompt_view.py → prompt_api_view.py} +7 -7
  64. iatoolkit/views/signup_view.py +38 -29
  65. iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
  66. iatoolkit/views/tasks_review_api_view.py +55 -0
  67. iatoolkit/views/{user_feedback_view.py → user_feedback_api_view.py} +16 -31
  68. iatoolkit/views/verify_user_view.py +13 -8
  69. {iatoolkit-0.8.1.dist-info → iatoolkit-0.63.4.dist-info}/METADATA +2 -2
  70. iatoolkit-0.63.4.dist-info/RECORD +113 -0
  71. {iatoolkit-0.8.1.dist-info → iatoolkit-0.63.4.dist-info}/top_level.txt +0 -1
  72. iatoolkit/common/auth.py +0 -200
  73. iatoolkit/static/images/arrow_up.png +0 -0
  74. iatoolkit/static/images/diagrama_iatoolkit.jpg +0 -0
  75. iatoolkit/static/images/logo_clinica.png +0 -0
  76. iatoolkit/static/images/logo_iatoolkit.png +0 -0
  77. iatoolkit/static/images/logo_maxxa.png +0 -0
  78. iatoolkit/static/images/logo_notaria.png +0 -0
  79. iatoolkit/static/images/logo_tarjeta.png +0 -0
  80. iatoolkit/static/images/logo_umayor.png +0 -0
  81. iatoolkit/static/images/upload.png +0 -0
  82. iatoolkit/static/js/chat_history.js +0 -117
  83. iatoolkit/templates/home.html +0 -201
  84. iatoolkit/templates/login.html +0 -43
  85. iatoolkit/views/chat_token_request_view.py +0 -98
  86. iatoolkit/views/chat_view.py +0 -51
  87. iatoolkit/views/download_file_view.py +0 -58
  88. iatoolkit/views/external_chat_login_view.py +0 -88
  89. iatoolkit/views/history_view.py +0 -57
  90. iatoolkit/views/llmquery_view.py +0 -65
  91. iatoolkit/views/tasks_review_view.py +0 -83
  92. iatoolkit-0.8.1.dist-info/RECORD +0 -175
  93. tests/__init__.py +0 -5
  94. tests/common/__init__.py +0 -0
  95. tests/common/test_auth.py +0 -279
  96. tests/common/test_routes.py +0 -42
  97. tests/common/test_session_manager.py +0 -59
  98. tests/common/test_util.py +0 -444
  99. tests/companies/__init__.py +0 -5
  100. tests/conftest.py +0 -36
  101. tests/infra/__init__.py +0 -5
  102. tests/infra/connectors/__init__.py +0 -5
  103. tests/infra/connectors/test_google_drive_connector.py +0 -107
  104. tests/infra/connectors/test_local_file_connector.py +0 -85
  105. tests/infra/connectors/test_s3_connector.py +0 -95
  106. tests/infra/test_call_service.py +0 -92
  107. tests/infra/test_database_manager.py +0 -59
  108. tests/infra/test_gemini_adapter.py +0 -137
  109. tests/infra/test_google_chat_app.py +0 -68
  110. tests/infra/test_llm_client.py +0 -165
  111. tests/infra/test_llm_proxy.py +0 -122
  112. tests/infra/test_mail_app.py +0 -94
  113. tests/infra/test_openai_adapter.py +0 -105
  114. tests/infra/test_redis_session_manager_service.py +0 -117
  115. tests/repositories/__init__.py +0 -5
  116. tests/repositories/test_database_manager.py +0 -87
  117. tests/repositories/test_document_repo.py +0 -76
  118. tests/repositories/test_llm_query_repo.py +0 -340
  119. tests/repositories/test_models.py +0 -38
  120. tests/repositories/test_profile_repo.py +0 -142
  121. tests/repositories/test_tasks_repo.py +0 -76
  122. tests/repositories/test_vs_repo.py +0 -107
  123. tests/services/__init__.py +0 -5
  124. tests/services/test_dispatcher_service.py +0 -274
  125. tests/services/test_document_service.py +0 -181
  126. tests/services/test_excel_service.py +0 -208
  127. tests/services/test_file_processor_service.py +0 -121
  128. tests/services/test_history_service.py +0 -164
  129. tests/services/test_jwt_service.py +0 -255
  130. tests/services/test_load_documents_service.py +0 -112
  131. tests/services/test_mail_service.py +0 -70
  132. tests/services/test_profile_service.py +0 -379
  133. tests/services/test_prompt_manager_service.py +0 -190
  134. tests/services/test_query_service.py +0 -243
  135. tests/services/test_search_service.py +0 -39
  136. tests/services/test_sql_service.py +0 -160
  137. tests/services/test_tasks_service.py +0 -252
  138. tests/services/test_user_feedback_service.py +0 -389
  139. tests/services/test_user_session_context_service.py +0 -132
  140. tests/views/__init__.py +0 -5
  141. tests/views/test_change_password_view.py +0 -191
  142. tests/views/test_chat_token_request_view.py +0 -188
  143. tests/views/test_chat_view.py +0 -98
  144. tests/views/test_download_file_view.py +0 -149
  145. tests/views/test_external_chat_login_view.py +0 -120
  146. tests/views/test_external_login_view.py +0 -102
  147. tests/views/test_file_store_view.py +0 -128
  148. tests/views/test_forgot_password_view.py +0 -142
  149. tests/views/test_history_view.py +0 -336
  150. tests/views/test_home_view.py +0 -61
  151. tests/views/test_llm_query_view.py +0 -154
  152. tests/views/test_login_view.py +0 -114
  153. tests/views/test_prompt_view.py +0 -111
  154. tests/views/test_signup_view.py +0 -140
  155. tests/views/test_tasks_review_view.py +0 -104
  156. tests/views/test_tasks_view.py +0 -130
  157. tests/views/test_user_feedback_view.py +0 -214
  158. tests/views/test_verify_user_view.py +0 -110
  159. {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
- external_user_id: str,
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
- 'external_user_id': external_user_id,
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 company {company_id}, user {external_user_id}: {e}")
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, expected_company_short_name: str) -> Optional[Dict[str, Any]]:
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
- if payload.get('company_short_name') != expected_company_short_name:
63
- logging.warning(
64
- f"Validación JWT fallida: company_short_name no coincide. "
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
- # external_user_id debe estar presente
70
- if 'external_user_id' not in payload or not payload['external_user_id']:
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 para {expected_company_short_name}. Error: {e}")
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 para {expected_company_short_name}: {e}")
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 datetime import datetime, timezone
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
- query_service: QueryService,
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 exits
38
+ # check if user exists
40
39
  user = self.profile_repo.get_user_by_email(email)
41
40
  if not user:
42
- return {"error": "Usuario no encontrado"}
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 {"error": "Contraseña inválida"}
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 {"error": "Empresa no encontrada"}
49
+ return {'success': False, "message": "Empresa no encontrada"}
51
50
 
52
- # check that user belongs to company
51
+ # check that user belongs to company
53
52
  if company not in user.companies:
54
- return {"error": "Usuario no esta autorizado para esta empresa"}
53
+ return {'success': False, "message": "Usuario no esta autorizado para esta empresa"}
55
54
 
56
55
  if not user.verified:
57
- return {"error": "Tu cuenta no ha sido verificada. Por favor, revisa tu correo."}
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
- # clear history save user data into session manager
60
- self.set_user_session(user=user, company=company)
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
- # initialize company context
63
- self.query_service.llm_init_context(
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
- return {"message": "Login exitoso"}
69
- except Exception as e:
70
- logging.exception(f"login error: {str(e)}")
71
- return {"error": str(e)}
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 set_user_session(self, user: User, company: Company):
74
- SessionManager.set('user_id', user.id)
75
- SessionManager.set('company_id', company.id)
76
- SessionManager.set('company_short_name', company.short_name)
77
-
78
- # save user data into session manager
79
- user_data = {
80
- "id": user.id,
81
- "email": user.email,
82
- "user_fullname": f'{user.first_name} {user.last_name}',
83
- "company_id": company.id,
84
- "company": company.name,
85
- "company_short_name": company.short_name,
86
- "user_is_local": True, # origin of data
87
- "extras": {} # company specific data
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
- # save time session was activated (in timestamp format)
92
- SessionManager.set('last_activity', datetime.now(timezone.utc).timestamp())
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
- 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,