iatoolkit 0.11.0__py3-none-any.whl → 0.71.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. iatoolkit/__init__.py +2 -6
  2. iatoolkit/base_company.py +9 -29
  3. iatoolkit/cli_commands.py +1 -1
  4. iatoolkit/common/routes.py +96 -52
  5. iatoolkit/common/session_manager.py +2 -1
  6. iatoolkit/common/util.py +17 -27
  7. iatoolkit/company_registry.py +1 -2
  8. iatoolkit/iatoolkit.py +97 -53
  9. iatoolkit/infra/llm_client.py +15 -20
  10. iatoolkit/infra/llm_proxy.py +38 -10
  11. iatoolkit/infra/openai_adapter.py +1 -1
  12. iatoolkit/infra/redis_session_manager.py +48 -2
  13. iatoolkit/locales/en.yaml +167 -0
  14. iatoolkit/locales/es.yaml +163 -0
  15. iatoolkit/repositories/database_manager.py +23 -3
  16. iatoolkit/repositories/document_repo.py +1 -1
  17. iatoolkit/repositories/models.py +35 -10
  18. iatoolkit/repositories/profile_repo.py +3 -2
  19. iatoolkit/repositories/vs_repo.py +26 -20
  20. iatoolkit/services/auth_service.py +193 -0
  21. iatoolkit/services/branding_service.py +70 -25
  22. iatoolkit/services/company_context_service.py +155 -0
  23. iatoolkit/services/configuration_service.py +133 -0
  24. iatoolkit/services/dispatcher_service.py +80 -105
  25. iatoolkit/services/document_service.py +5 -2
  26. iatoolkit/services/embedding_service.py +146 -0
  27. iatoolkit/services/excel_service.py +30 -26
  28. iatoolkit/services/file_processor_service.py +4 -12
  29. iatoolkit/services/history_service.py +7 -16
  30. iatoolkit/services/i18n_service.py +104 -0
  31. iatoolkit/services/jwt_service.py +18 -29
  32. iatoolkit/services/language_service.py +83 -0
  33. iatoolkit/services/load_documents_service.py +100 -113
  34. iatoolkit/services/mail_service.py +9 -4
  35. iatoolkit/services/profile_service.py +152 -76
  36. iatoolkit/services/prompt_manager_service.py +20 -16
  37. iatoolkit/services/query_service.py +208 -96
  38. iatoolkit/services/search_service.py +11 -4
  39. iatoolkit/services/sql_service.py +57 -25
  40. iatoolkit/services/tasks_service.py +1 -1
  41. iatoolkit/services/user_feedback_service.py +72 -34
  42. iatoolkit/services/user_session_context_service.py +112 -54
  43. iatoolkit/static/images/fernando.jpeg +0 -0
  44. iatoolkit/static/js/chat_feedback_button.js +80 -0
  45. iatoolkit/static/js/chat_help_content.js +124 -0
  46. iatoolkit/static/js/chat_history_button.js +110 -0
  47. iatoolkit/static/js/chat_logout_button.js +36 -0
  48. iatoolkit/static/js/chat_main.js +135 -222
  49. iatoolkit/static/js/chat_onboarding_button.js +103 -0
  50. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  51. iatoolkit/static/js/chat_reload_button.js +35 -0
  52. iatoolkit/static/styles/chat_iatoolkit.css +289 -210
  53. iatoolkit/static/styles/chat_modal.css +63 -77
  54. iatoolkit/static/styles/chat_public.css +107 -0
  55. iatoolkit/static/styles/landing_page.css +182 -0
  56. iatoolkit/static/styles/onboarding.css +176 -0
  57. iatoolkit/system_prompts/query_main.prompt +5 -22
  58. iatoolkit/templates/_company_header.html +20 -0
  59. iatoolkit/templates/_login_widget.html +42 -0
  60. iatoolkit/templates/base.html +40 -20
  61. iatoolkit/templates/change_password.html +57 -36
  62. iatoolkit/templates/chat.html +180 -86
  63. iatoolkit/templates/chat_modals.html +138 -68
  64. iatoolkit/templates/error.html +44 -8
  65. iatoolkit/templates/forgot_password.html +40 -23
  66. iatoolkit/templates/index.html +145 -0
  67. iatoolkit/templates/login_simulation.html +45 -0
  68. iatoolkit/templates/onboarding_shell.html +107 -0
  69. iatoolkit/templates/signup.html +63 -65
  70. iatoolkit/views/base_login_view.py +91 -0
  71. iatoolkit/views/change_password_view.py +56 -31
  72. iatoolkit/views/embedding_api_view.py +65 -0
  73. iatoolkit/views/external_login_view.py +61 -28
  74. iatoolkit/views/{file_store_view.py → file_store_api_view.py} +10 -3
  75. iatoolkit/views/forgot_password_view.py +27 -21
  76. iatoolkit/views/help_content_api_view.py +54 -0
  77. iatoolkit/views/history_api_view.py +56 -0
  78. iatoolkit/views/home_view.py +50 -23
  79. iatoolkit/views/index_view.py +14 -0
  80. iatoolkit/views/init_context_api_view.py +74 -0
  81. iatoolkit/views/llmquery_api_view.py +58 -0
  82. iatoolkit/views/login_simulation_view.py +93 -0
  83. iatoolkit/views/login_view.py +130 -37
  84. iatoolkit/views/logout_api_view.py +49 -0
  85. iatoolkit/views/profile_api_view.py +46 -0
  86. iatoolkit/views/{prompt_view.py → prompt_api_view.py} +10 -10
  87. iatoolkit/views/signup_view.py +41 -36
  88. iatoolkit/views/{tasks_view.py → tasks_api_view.py} +10 -36
  89. iatoolkit/views/tasks_review_api_view.py +55 -0
  90. iatoolkit/views/user_feedback_api_view.py +60 -0
  91. iatoolkit/views/verify_user_view.py +34 -29
  92. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/METADATA +41 -23
  93. iatoolkit-0.71.2.dist-info/RECORD +122 -0
  94. iatoolkit-0.71.2.dist-info/licenses/LICENSE +21 -0
  95. iatoolkit/common/auth.py +0 -200
  96. iatoolkit/static/images/arrow_up.png +0 -0
  97. iatoolkit/static/images/diagrama_iatoolkit.jpg +0 -0
  98. iatoolkit/static/images/logo_clinica.png +0 -0
  99. iatoolkit/static/images/logo_iatoolkit.png +0 -0
  100. iatoolkit/static/images/logo_maxxa.png +0 -0
  101. iatoolkit/static/images/logo_notaria.png +0 -0
  102. iatoolkit/static/images/logo_tarjeta.png +0 -0
  103. iatoolkit/static/images/logo_umayor.png +0 -0
  104. iatoolkit/static/images/upload.png +0 -0
  105. iatoolkit/static/js/chat_feedback.js +0 -115
  106. iatoolkit/static/js/chat_history.js +0 -117
  107. iatoolkit/static/styles/chat_info.css +0 -53
  108. iatoolkit/templates/header.html +0 -31
  109. iatoolkit/templates/home.html +0 -199
  110. iatoolkit/templates/login.html +0 -43
  111. iatoolkit/templates/test.html +0 -9
  112. iatoolkit/views/chat_token_request_view.py +0 -98
  113. iatoolkit/views/chat_view.py +0 -58
  114. iatoolkit/views/download_file_view.py +0 -58
  115. iatoolkit/views/external_chat_login_view.py +0 -95
  116. iatoolkit/views/history_view.py +0 -57
  117. iatoolkit/views/llmquery_view.py +0 -65
  118. iatoolkit/views/tasks_review_view.py +0 -83
  119. iatoolkit/views/user_feedback_view.py +0 -74
  120. iatoolkit-0.11.0.dist-info/RECORD +0 -110
  121. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/WHEEL +0 -0
  122. {iatoolkit-0.11.0.dist-info → iatoolkit-0.71.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,193 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from flask import request
7
+ from injector import inject
8
+ from iatoolkit.services.profile_service import ProfileService
9
+ from iatoolkit.services.jwt_service import JWTService
10
+ from iatoolkit.services.i18n_service import I18nService
11
+ from iatoolkit.repositories.database_manager import DatabaseManager
12
+ from iatoolkit.repositories.models import AccessLog
13
+ from flask import request
14
+ import logging
15
+ import hashlib
16
+
17
+
18
+ class AuthService:
19
+ """
20
+ Centralized service for handling authentication for all incoming requests.
21
+ It determines the user's identity based on either a Flask session cookie or an API Key.
22
+ """
23
+
24
+ @inject
25
+ def __init__(self, profile_service: ProfileService,
26
+ jwt_service: JWTService,
27
+ db_manager: DatabaseManager,
28
+ i18n_service: I18nService
29
+ ):
30
+ self.profile_service = profile_service
31
+ self.jwt_service = jwt_service
32
+ self.db_manager = db_manager
33
+ self.i18n_service = i18n_service
34
+
35
+ def login_local_user(self, company_short_name: str, email: str, password: str) -> dict:
36
+ # try to autenticate a local user, register the event and return the result
37
+ auth_response = self.profile_service.login(
38
+ company_short_name=company_short_name,
39
+ email=email,
40
+ password=password,
41
+ )
42
+
43
+ if not auth_response.get('success'):
44
+ self.log_access(
45
+ company_short_name=company_short_name,
46
+ user_identifier=email,
47
+ auth_type='local',
48
+ outcome='failure',
49
+ reason_code='INVALID_CREDENTIALS',
50
+ )
51
+ else:
52
+ self.log_access(
53
+ company_short_name=company_short_name,
54
+ auth_type='local',
55
+ outcome='success',
56
+ user_identifier=auth_response.get('user_identifier')
57
+ )
58
+
59
+ return auth_response
60
+
61
+ def redeem_token_for_session(self, company_short_name: str, token: str) -> dict:
62
+ # redeem a token for a session, register the event and return the result
63
+ payload = self.jwt_service.validate_chat_jwt(token)
64
+
65
+ if not payload:
66
+ self.log_access(
67
+ company_short_name=company_short_name,
68
+ auth_type='redeem_token',
69
+ outcome='failure',
70
+ reason_code='JWT_INVALID'
71
+ )
72
+ return {'success': False, 'error': self.i18n_service.t('errors.auth.invalid_or_expired_token')}
73
+
74
+ # 2. if token is valid, extract the user_identifier
75
+ user_identifier = payload.get('user_identifier')
76
+ try:
77
+ # create the Flask session
78
+ self.profile_service.set_session_for_user(company_short_name, user_identifier)
79
+ self.log_access(
80
+ company_short_name=company_short_name,
81
+ auth_type='redeem_token',
82
+ outcome='success',
83
+ user_identifier=user_identifier
84
+ )
85
+ return {'success': True, 'user_identifier': user_identifier}
86
+ except Exception as e:
87
+ logging.error(f"error creeating session for Token of {user_identifier}: {e}")
88
+ self.log_access(
89
+ company_short_name=company_short_name,
90
+ auth_type='redeem_token',
91
+ outcome='failure',
92
+ reason_code='SESSION_CREATION_FAILED',
93
+ user_identifier=user_identifier
94
+ )
95
+ return {'success': False, 'error': self.i18n_service.t('errors.auth.session_creation_failed')}
96
+
97
+ def verify(self, anonymous: bool = False) -> dict:
98
+ """
99
+ Verifies the current request and identifies the user.
100
+ If anonymous is True the non-presence of use_identifier is ignored
101
+
102
+ Returns a dictionary with:
103
+ - success: bool
104
+ - user_identifier: str (if successful)
105
+ - company_short_name: str (if successful)
106
+ - error_message: str (on failure)
107
+ - status_code: int (on failure)
108
+ """
109
+ # --- Priority 1: Check for a valid Flask web session ---
110
+ session_info = self.profile_service.get_current_session_info()
111
+ if session_info and session_info.get('user_identifier'):
112
+ # User is authenticated via a web session cookie.
113
+ return {
114
+ "success": True,
115
+ "company_short_name": session_info['company_short_name'],
116
+ "user_identifier": session_info['user_identifier'],
117
+ }
118
+
119
+ # --- Priority 2: Check for a valid API Key in headers ---
120
+ api_key = None
121
+ auth = request.headers.get('Authorization', '')
122
+ if isinstance(auth, str) and auth.lower().startswith('bearer '):
123
+ api_key = auth.split(' ', 1)[1].strip()
124
+
125
+ if not api_key:
126
+ # --- Failure: No valid credentials found ---
127
+ logging.info(f"Authentication required. No session cookie or API Key provided.")
128
+ return {"success": False,
129
+ "error_message": self.i18n_service.t('errors.auth.authentication_required'),
130
+ "status_code": 401}
131
+
132
+ # check if the api-key is valid and active
133
+ api_key_entry = self.profile_service.get_active_api_key_entry(api_key)
134
+ if not api_key_entry:
135
+ logging.info(f"Invalid or inactive API Key {api_key}")
136
+ return {"success": False,
137
+ "error_message": self.i18n_service.t('errors.auth.invalid_api_key'),
138
+ "status_code": 402}
139
+
140
+ # get the company from the api_key_entry
141
+ company = api_key_entry.company
142
+
143
+ # For API calls, the external_user_id must be provided in the request.
144
+ data = request.get_json(silent=True) or {}
145
+ user_identifier = data.get('user_identifier', '')
146
+ if not anonymous and not user_identifier:
147
+ logging.info(f"No user_identifier provided for API call.")
148
+ return {"success": False,
149
+ "error_message": self.i18n_service.t('errors.auth.no_user_identifier_api'),
150
+ "status_code": 403}
151
+
152
+ return {
153
+ "success": True,
154
+ "company_short_name": company.short_name,
155
+ "user_identifier": user_identifier
156
+ }
157
+
158
+
159
+ def log_access(self,
160
+ company_short_name: str,
161
+ auth_type: str,
162
+ outcome: str,
163
+ user_identifier: str = None,
164
+ reason_code: str = None):
165
+ """
166
+ Registra un intento de acceso en la base de datos.
167
+ Es "best-effort" y no debe interrumpir el flujo de autenticación.
168
+ """
169
+ session = self.db_manager.scoped_session()
170
+ try:
171
+ # Capturar datos del contexto de la petición de Flask
172
+ source_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
173
+ path = request.path
174
+ ua = request.headers.get('User-Agent', '')
175
+ ua_hash = hashlib.sha256(ua.encode()).hexdigest()[:16] if ua else None
176
+
177
+ # Crear la entrada de log
178
+ log_entry = AccessLog(
179
+ company_short_name=company_short_name,
180
+ user_identifier=user_identifier,
181
+ auth_type=auth_type,
182
+ outcome=outcome,
183
+ reason_code=reason_code,
184
+ source_ip=source_ip,
185
+ user_agent_hash=ua_hash,
186
+ request_path=path,
187
+ )
188
+ session.add(log_entry)
189
+ session.commit()
190
+
191
+ except Exception as e:
192
+ logging.error(f"error writting to AccessLog: {e}", exc_info=False)
193
+ session.rollback()
@@ -4,14 +4,17 @@
4
4
  # IAToolkit is open source software.
5
5
 
6
6
  from iatoolkit.repositories.models import Company
7
+ from iatoolkit.services.configuration_service import ConfigurationService
8
+ from injector import inject
7
9
 
8
10
 
9
11
  class BrandingService:
10
12
  """
11
- Servicio centralizado que gestiona la configuración de branding.
13
+ Branding configuration for IAToolkit
12
14
  """
13
-
14
- def __init__(self):
15
+ @inject
16
+ def __init__(self, config_service: ConfigurationService):
17
+ self.config_service = config_service
15
18
  """
16
19
  Define los estilos de branding por defecto para la aplicación.
17
20
  """
@@ -19,13 +22,16 @@ class BrandingService:
19
22
  # --- Estilos del Encabezado Principal ---
20
23
  "header_background_color": "#FFFFFF",
21
24
  "header_text_color": "#6C757D",
22
- "primary_font_weight": "bold",
23
- "primary_font_size": "1rem",
24
- "secondary_font_weight": "600",
25
- "secondary_font_size": "0.875rem",
26
- "tertiary_font_weight": "normal",
27
- "tertiary_font_size": "0.75rem",
28
- "tertiary_opacity": "0.8",
25
+ "primary_font_weight": "600",
26
+ "primary_font_size": "1.2rem",
27
+ "secondary_font_weight": "400",
28
+ "secondary_font_size": "0.9rem",
29
+ "tertiary_font_weight": "300",
30
+ "tertiary_font_size": "0.8rem",
31
+ "tertiary_opacity": "0.7",
32
+
33
+ # headings
34
+ "brand_text_heading_color": "#334155", # Gris pizarra por defecto
29
35
 
30
36
  # Estilos Globales de la Marca ---
31
37
  "brand_primary_color": "#0d6efd", # Azul de Bootstrap por defecto
@@ -40,29 +46,48 @@ class BrandingService:
40
46
  "brand_danger_border": "#f5c2c7", # Borde rojo intermedio
41
47
 
42
48
  # Estilos para Alertas Informativas ---
43
- "brand_info_bg": "#cff4fc", # Fondo celeste pálido
44
- "brand_info_text": "#055160", # Texto azul oscuro
45
- "brand_info_border": "#b6effb",
49
+ "brand_info_bg": "#F0F4F8", # Un fondo de gris azulado muy pálido
50
+ "brand_info_text": "#0d6efd", # Texto en el color primario
51
+ "brand_info_border": "#D9E2EC", # Borde de gris azulado pálido
52
+
53
+ # Estilos para el Asistente de Prompts ---
54
+ "prompt_assistant_bg": "#f8f9fa",
55
+ "prompt_assistant_border": "#dee2e6",
56
+ "prompt_assistant_button_bg": "#FFFFFF",
57
+ "prompt_assistant_button_text": "#495057",
58
+ "prompt_assistant_button_border": "#ced4da",
59
+ "prompt_assistant_dropdown_bg": "#f8f9fa",
60
+ "prompt_assistant_header_bg": "#e9ecef",
61
+ "prompt_assistant_header_text": "#495057",
62
+
63
+ # this use the primary by default
64
+ "prompt_assistant_icon_color": None,
65
+ "prompt_assistant_item_hover_bg": None,
66
+ "prompt_assistant_item_hover_text": None,
46
67
 
47
68
  # Color para el botón de Enviar ---
48
- "send_button_color": "#212529" # Gris oscuro/casi negro por defecto
69
+ "send_button_color": "#212529" # Gris oscuro/casi negro por defecto
49
70
  }
50
71
 
51
- def get_company_branding(self, company: Company | None) -> dict:
72
+ def get_company_branding(self, company_short_name: str) -> dict:
52
73
  """
53
74
  Retorna los estilos de branding finales para una compañía,
54
75
  fusionando los valores por defecto con los personalizados.
55
76
  """
56
77
  final_branding_values = self._default_branding.copy()
78
+ branding_data = self.config_service.get_configuration(company_short_name, 'branding')
79
+ final_branding_values.update(branding_data)
80
+
57
81
 
58
- if company and company.branding:
59
- final_branding_values.update(company.branding)
82
+ # Función para convertir HEX a RGB
83
+ def hex_to_rgb(hex_color):
84
+ hex_color = hex_color.lstrip('#')
85
+ return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
86
+
87
+ primary_rgb = hex_to_rgb(final_branding_values['brand_primary_color'])
88
+ secondary_rgb = hex_to_rgb(final_branding_values['brand_secondary_color'])
60
89
 
61
90
  # --- CONSTRUCCIÓN DE ESTILOS Y VARIABLES CSS ---
62
- header_style = (
63
- f"background-color: {final_branding_values['header_background_color']}; "
64
- f"color: {final_branding_values['header_text_color']};"
65
- )
66
91
  primary_text_style = (
67
92
  f"font-weight: {final_branding_values['primary_font_weight']}; "
68
93
  f"font-size: {final_branding_values['primary_font_size']};"
@@ -82,6 +107,12 @@ class BrandingService:
82
107
  :root {{
83
108
  --brand-primary-color: {final_branding_values['brand_primary_color']};
84
109
  --brand-secondary-color: {final_branding_values['brand_secondary_color']};
110
+ --brand-header-bg: {final_branding_values['header_background_color']};
111
+ --brand-header-text: {final_branding_values['header_text_color']};
112
+ --brand-text-heading-color: {final_branding_values['brand_text_heading_color']};
113
+
114
+ --brand-primary-color-rgb: {', '.join(map(str, primary_rgb))};
115
+ --brand-secondary-color-rgb: {', '.join(map(str, secondary_rgb))};
85
116
  --brand-text-on-primary: {final_branding_values['brand_text_on_primary']};
86
117
  --brand-text-on-secondary: {final_branding_values['brand_text_on_secondary']};
87
118
  --brand-modal-header-bg: {final_branding_values['header_background_color']};
@@ -91,18 +122,32 @@ class BrandingService:
91
122
  --brand-danger-text: {final_branding_values['brand_danger_text']};
92
123
  --brand-danger-border: {final_branding_values['brand_danger_border']};
93
124
  --brand-info-bg: {final_branding_values['brand_info_bg']};
94
- --brand-info-text: {final_branding_values['brand_info_text']};
125
+ --brand-info-text: {final_branding_values['brand_info_text'] or final_branding_values['brand_primary_color']};
95
126
  --brand-info-border: {final_branding_values['brand_info_border']};
127
+ --brand-prompt-assistant-bg: {final_branding_values['prompt_assistant_bg']};
128
+ --brand-prompt-assistant-border: {final_branding_values['prompt_assistant_border']};
129
+ --brand-prompt-assistant-icon-color: {final_branding_values['prompt_assistant_icon_color'] or final_branding_values['brand_primary_color']};
130
+ --brand-prompt-assistant-button-bg: {final_branding_values['prompt_assistant_button_bg']};
131
+ --brand-prompt-assistant-button-text: {final_branding_values['prompt_assistant_button_text']};
132
+ --brand-prompt-assistant-button-border: {final_branding_values['prompt_assistant_button_border']};
133
+ --brand-prompt-assistant-dropdown-bg: {final_branding_values['prompt_assistant_dropdown_bg']};
134
+ --brand-prompt-assistant-header-bg: {final_branding_values['prompt_assistant_header_bg']};
135
+ --brand-prompt-assistant-header-text: {final_branding_values['prompt_assistant_header_text']};
136
+ --brand-prompt-assistant-item-hover-bg: {final_branding_values['prompt_assistant_item_hover_bg'] or final_branding_values['brand_primary_color']};
137
+ --brand-prompt-assistant-item-hover-text: {final_branding_values['prompt_assistant_item_hover_text'] or final_branding_values['brand_text_on_primary']};
96
138
 
97
139
  }}
98
140
  """
99
141
 
142
+ # get the company name from configuration for the branding render
143
+ company_name = self.config_service.get_configuration(company_short_name, 'name')
144
+
100
145
  return {
101
- "name": company.name if company else "IAToolkit",
102
- "header_style": header_style,
146
+ "name": company_name,
103
147
  "primary_text_style": primary_text_style,
104
148
  "secondary_text_style": secondary_text_style,
105
149
  "tertiary_text_style": tertiary_text_style,
106
150
  "header_text_color": final_branding_values['header_text_color'],
107
- "css_variables": css_variables
151
+ "css_variables": css_variables,
152
+ "send_button_color": final_branding_values['brand_primary_color']
108
153
  }
@@ -0,0 +1,155 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from iatoolkit.common.util import Utility
7
+ from iatoolkit.services.configuration_service import ConfigurationService
8
+ from iatoolkit.services.sql_service import SqlService
9
+ from iatoolkit.common.exceptions import IAToolkitException
10
+ import logging
11
+ from injector import inject
12
+ import os
13
+
14
+
15
+ class CompanyContextService:
16
+ """
17
+ Responsible for building the complete context string for a given company
18
+ to be sent to the Language Model.
19
+ """
20
+
21
+ @inject
22
+ def __init__(self,
23
+ sql_service: SqlService,
24
+ utility: Utility,
25
+ config_service: ConfigurationService):
26
+ self.sql_service = sql_service
27
+ self.utility = utility
28
+ self.config_service = config_service
29
+
30
+ def get_company_context(self, company_short_name: str) -> str:
31
+ """
32
+ Builds the full context by aggregating three sources:
33
+ 1. Static context files (Markdown).
34
+ 2. Static schema files (YAML for APIs, etc.).
35
+ 3. Dynamic SQL database schema from the live connection.
36
+ """
37
+ context_parts = []
38
+
39
+ # 1. Context from Markdown (context/*.md) and yaml (schema/*.yaml) files
40
+ try:
41
+ md_context = self._get_static_file_context(company_short_name)
42
+ if md_context:
43
+ context_parts.append(md_context)
44
+ except Exception as e:
45
+ logging.warning(f"Could not load Markdown context for '{company_short_name}': {e}")
46
+
47
+ # 2. Context from company-specific Python logic (SQL schemas)
48
+ try:
49
+ sql_context = self._get_sql_schema_context(company_short_name)
50
+ if sql_context:
51
+ context_parts.append(sql_context)
52
+ except Exception as e:
53
+ logging.warning(f"Could not generate SQL context for '{company_short_name}': {e}")
54
+
55
+ # Join all parts with a clear separator
56
+ return "\n\n---\n\n".join(context_parts)
57
+
58
+ def _get_static_file_context(self, company_short_name: str) -> str:
59
+ # Get context from .md and .yaml schema files.
60
+ static_context = ''
61
+
62
+ # Part 1: Markdown context files
63
+ context_dir = f'companies/{company_short_name}/context'
64
+ if os.path.exists(context_dir):
65
+ context_files = self.utility.get_files_by_extension(context_dir, '.md', return_extension=True)
66
+ for file in context_files:
67
+ filepath = os.path.join(context_dir, file)
68
+ static_context += self.utility.load_markdown_context(filepath)
69
+
70
+ # Part 2: YAML schema files
71
+ schema_dir = f'companies/{company_short_name}/schema'
72
+ if os.path.exists(schema_dir):
73
+ schema_files = self.utility.get_files_by_extension(schema_dir, '.yaml', return_extension=True)
74
+ for file in schema_files:
75
+ schema_name = file.split('.')[0] # Use full filename as entity name
76
+ filepath = os.path.join(schema_dir, file)
77
+ static_context += self.utility.generate_context_for_schema(schema_name, filepath)
78
+
79
+ return static_context
80
+
81
+ def _get_sql_schema_context(self, company_short_name: str) -> str:
82
+ """
83
+ Generates the SQL schema context by inspecting live database connections
84
+ based on the flexible company.yaml configuration.
85
+ It supports including all tables and providing specific overrides for a subset of them.
86
+ """
87
+ data_sources_config = self.config_service.get_configuration(company_short_name, 'data_sources')
88
+ if not data_sources_config or not data_sources_config.get('sql'):
89
+ return ''
90
+
91
+ sql_context = ''
92
+ for source in data_sources_config.get('sql', []):
93
+ db_name = source.get('database')
94
+ if not db_name:
95
+ continue
96
+
97
+ try:
98
+ db_manager = self.sql_service.get_database_manager(db_name)
99
+ except IAToolkitException as e:
100
+ logging.warning(f"Could not get DB manager for '{db_name}': {e}")
101
+ continue
102
+
103
+ db_description = source.get('description', '')
104
+ sql_context = f'***Base de datos (database_name)***: {db_name}\n'
105
+ sql_context += f"**Descripción:**: {db_description}\n" if db_description else ""
106
+ sql_context += "Para consultar esta base de datos debes utilizar el servicio ***iat_sql_query***.\n"
107
+
108
+ # 1. get the list of tables to process.
109
+ tables_to_process = []
110
+ if source.get('include_all_tables', False):
111
+ all_tables = db_manager.get_all_table_names()
112
+ tables_to_exclude = set(source.get('exclude_tables', []))
113
+ tables_to_process = [t for t in all_tables if t not in tables_to_exclude]
114
+ elif 'tables' in source:
115
+ # if not include_all_tables, use the list of tables explicitly specified in the map.
116
+ tables_to_process = list(source['tables'].keys())
117
+
118
+ # 2. get the global settings and overrides.
119
+ global_exclude_columns = source.get('exclude_columns', [])
120
+ table_prefix = source.get('table_prefix')
121
+ table_overrides = source.get('tables', {})
122
+
123
+ # 3. iterate over the tables.
124
+ for table_name in tables_to_process:
125
+ try:
126
+ # 4. get the table specific configuration.
127
+ table_config = table_overrides.get(table_name, {})
128
+
129
+ # 5. define the schema name, using the override if it exists.
130
+ # Priority 1: Explicit override from the 'tables' map.
131
+ schema_name = table_config.get('schema_name')
132
+
133
+ if not schema_name:
134
+ # Priority 2: Automatic prefix stripping.
135
+ if table_prefix and table_name.startswith(table_prefix):
136
+ schema_name = table_name[len(table_prefix):]
137
+ else:
138
+ # Priority 3: Default to the table name itself.
139
+ schema_name = table_name
140
+
141
+ # 6. define the list of columns to exclude, (local vs. global).
142
+ local_exclude_columns = table_config.get('exclude_columns')
143
+ final_exclude_columns = local_exclude_columns if local_exclude_columns is not None else global_exclude_columns
144
+
145
+ # 7. get the table schema definition.
146
+ table_definition = db_manager.get_table_schema(
147
+ table_name=table_name,
148
+ schema_name=schema_name,
149
+ exclude_columns=final_exclude_columns
150
+ )
151
+ sql_context += table_definition
152
+ except (KeyError, RuntimeError) as e:
153
+ logging.warning(f"Could not generate schema for table '{table_name}': {e}")
154
+
155
+ return sql_context
@@ -0,0 +1,133 @@
1
+ # iatoolkit/services/configuration_service.py
2
+ # Copyright (c) 2024 Fernando Libedinsky
3
+ # Product: IAToolkit
4
+
5
+ from pathlib import Path
6
+ from iatoolkit.repositories.models import Company
7
+ from iatoolkit.common.util import Utility
8
+ from injector import inject
9
+ import logging
10
+
11
+ class ConfigurationService:
12
+ """
13
+ Orchestrates the configuration of a Company by reading its YAML files
14
+ and using the BaseCompany's protected methods to register settings.
15
+ """
16
+
17
+ @inject
18
+ def __init__(self,
19
+ utility: Utility):
20
+ self.utility = utility
21
+ self._loaded_configs = {} # cache for store loaded configurations
22
+
23
+ def get_configuration(self, company_short_name: str, content_key: str):
24
+ """
25
+ Public method to provide a specific section of a company's configuration.
26
+ It uses a cache to avoid reading files from disk on every call.
27
+ """
28
+ self._ensure_config_loaded(company_short_name)
29
+ return self._loaded_configs[company_short_name].get(content_key)
30
+
31
+ def load_configuration(self, company_short_name: str, company_instance):
32
+ """
33
+ Main entry point for configuring a company instance.
34
+ This method is invoked by the dispatcher for each registered company.
35
+ """
36
+ logging.info(f"⚙️ Starting configuration for company '{company_short_name}'...")
37
+
38
+ # 1. Load the main configuration file and supplementary content files
39
+ config = self._load_and_merge_configs(company_short_name)
40
+
41
+ # 2. Register core company details and get the database object
42
+ company_db_object = self._register_core_details(company_instance, config)
43
+
44
+ # 3. Register tools (functions)
45
+ self._register_tools(company_instance, config.get('tools', []))
46
+
47
+ # 4. Register prompt categories and prompts
48
+ self._register_prompts(company_instance, config)
49
+
50
+ # 5. Link the persisted Company object back to the running instance
51
+ company_instance.company_short_name = company_short_name
52
+ company_instance.company = company_db_object
53
+ company_instance.id = company_instance.company.id
54
+
55
+ logging.info(f"✅ Company '{company_short_name}' configured successfully.")
56
+
57
+ def _ensure_config_loaded(self, company_short_name: str):
58
+ """
59
+ Checks if the configuration for a company is in the cache.
60
+ If not, it loads it from files and stores it.
61
+ """
62
+ if company_short_name not in self._loaded_configs:
63
+ self._loaded_configs[company_short_name] = self._load_and_merge_configs(company_short_name)
64
+
65
+ def _load_and_merge_configs(self, company_short_name: str) -> dict:
66
+ """
67
+ Loads the main company.yaml and merges data from supplementary files
68
+ specified in the 'content_files' section.
69
+ """
70
+ config_dir = Path("companies") / company_short_name / "config"
71
+ main_config_path = config_dir / "company.yaml"
72
+
73
+ if not main_config_path.exists():
74
+ raise FileNotFoundError(f"Main configuration file not found: {main_config_path}")
75
+
76
+ config = self.utility.load_schema_from_yaml(main_config_path)
77
+
78
+ # Load and merge supplementary content files (e.g., onboarding_cards)
79
+ for key, file_path in config.get('help_files', {}).items():
80
+ supplementary_path = config_dir / file_path
81
+ if supplementary_path.exists():
82
+ config[key] = self.utility.load_schema_from_yaml(supplementary_path)
83
+ else:
84
+ logging.warning(f"⚠️ Warning: Content file not found: {supplementary_path}")
85
+ config[key] = None # Ensure the key exists but is empty
86
+
87
+ return config
88
+
89
+ def _register_core_details(self, company_instance, config: dict) -> Company:
90
+ """Calls _create_company with data from the merged YAML config."""
91
+ return company_instance._create_company(
92
+ short_name=config['id'],
93
+ name=config['name'],
94
+ parameters=config.get('parameters', {})
95
+ )
96
+
97
+ def _register_tools(self, company_instance, tools_config: list):
98
+ """Calls _create_function for each tool defined in the YAML."""
99
+ for tool in tools_config:
100
+ company_instance._create_function(
101
+ function_name=tool['function_name'],
102
+ description=tool['description'],
103
+ params=tool['params']
104
+ )
105
+
106
+ def _register_prompts(self, company_instance, config: dict):
107
+ """
108
+ Creates prompt categories first, then creates each prompt and assigns
109
+ it to its respective category.
110
+ """
111
+ prompts_config = config.get('prompts', [])
112
+ categories_config = config.get('prompt_categories', [])
113
+
114
+ created_categories = {}
115
+ for i, category_name in enumerate(categories_config):
116
+ category_obj = company_instance._create_prompt_category(name=category_name, order=i + 1)
117
+ created_categories[category_name] = category_obj
118
+
119
+ for prompt_data in prompts_config:
120
+ category_name = prompt_data.get('category')
121
+ if not category_name or category_name not in created_categories:
122
+ logging.info(f"⚠️ Warning: Prompt '{prompt_data['name']}' has an invalid or missing category. Skipping.")
123
+ continue
124
+
125
+ category_obj = created_categories[category_name]
126
+ company_instance._create_prompt(
127
+ prompt_name=prompt_data['name'],
128
+ description=prompt_data['description'],
129
+ order=prompt_data['order'],
130
+ category=category_obj,
131
+ active=prompt_data.get('active', True),
132
+ custom_fields=prompt_data.get('custom_fields', [])
133
+ )