iatoolkit 0.71.4__py3-none-any.whl → 0.91.1__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 (86) hide show
  1. iatoolkit/__init__.py +15 -5
  2. iatoolkit/base_company.py +4 -58
  3. iatoolkit/cli_commands.py +6 -7
  4. iatoolkit/common/exceptions.py +1 -0
  5. iatoolkit/common/routes.py +12 -28
  6. iatoolkit/common/util.py +7 -1
  7. iatoolkit/company_registry.py +50 -14
  8. iatoolkit/{iatoolkit.py → core.py} +54 -55
  9. iatoolkit/infra/{mail_app.py → brevo_mail_app.py} +15 -37
  10. iatoolkit/infra/llm_client.py +9 -5
  11. iatoolkit/locales/en.yaml +10 -2
  12. iatoolkit/locales/es.yaml +171 -162
  13. iatoolkit/repositories/database_manager.py +59 -14
  14. iatoolkit/repositories/llm_query_repo.py +34 -22
  15. iatoolkit/repositories/models.py +16 -18
  16. iatoolkit/repositories/profile_repo.py +5 -10
  17. iatoolkit/repositories/vs_repo.py +9 -4
  18. iatoolkit/services/auth_service.py +1 -1
  19. iatoolkit/services/branding_service.py +1 -1
  20. iatoolkit/services/company_context_service.py +19 -11
  21. iatoolkit/services/configuration_service.py +219 -46
  22. iatoolkit/services/dispatcher_service.py +31 -225
  23. iatoolkit/services/document_service.py +10 -1
  24. iatoolkit/services/embedding_service.py +9 -6
  25. iatoolkit/services/excel_service.py +50 -2
  26. iatoolkit/services/history_manager_service.py +189 -0
  27. iatoolkit/services/jwt_service.py +1 -1
  28. iatoolkit/services/language_service.py +8 -2
  29. iatoolkit/services/license_service.py +82 -0
  30. iatoolkit/services/mail_service.py +171 -25
  31. iatoolkit/services/profile_service.py +37 -32
  32. iatoolkit/services/{prompt_manager_service.py → prompt_service.py} +110 -1
  33. iatoolkit/services/query_service.py +192 -191
  34. iatoolkit/services/sql_service.py +63 -12
  35. iatoolkit/services/tool_service.py +231 -0
  36. iatoolkit/services/user_feedback_service.py +18 -6
  37. iatoolkit/services/user_session_context_service.py +18 -0
  38. iatoolkit/static/images/iatoolkit_core.png +0 -0
  39. iatoolkit/static/images/iatoolkit_logo.png +0 -0
  40. iatoolkit/static/js/chat_feedback_button.js +1 -1
  41. iatoolkit/static/js/chat_help_content.js +4 -4
  42. iatoolkit/static/js/chat_main.js +17 -5
  43. iatoolkit/static/js/chat_onboarding_button.js +1 -1
  44. iatoolkit/static/styles/chat_iatoolkit.css +1 -1
  45. iatoolkit/static/styles/chat_public.css +28 -0
  46. iatoolkit/static/styles/documents.css +598 -0
  47. iatoolkit/static/styles/landing_page.css +223 -7
  48. iatoolkit/system_prompts/__init__.py +0 -0
  49. iatoolkit/system_prompts/query_main.prompt +2 -1
  50. iatoolkit/system_prompts/sql_rules.prompt +47 -12
  51. iatoolkit/templates/_company_header.html +30 -5
  52. iatoolkit/templates/_login_widget.html +3 -3
  53. iatoolkit/templates/chat.html +1 -1
  54. iatoolkit/templates/forgot_password.html +3 -2
  55. iatoolkit/templates/onboarding_shell.html +1 -1
  56. iatoolkit/templates/signup.html +3 -0
  57. iatoolkit/views/base_login_view.py +1 -1
  58. iatoolkit/views/change_password_view.py +1 -1
  59. iatoolkit/views/forgot_password_view.py +9 -4
  60. iatoolkit/views/history_api_view.py +3 -3
  61. iatoolkit/views/home_view.py +4 -2
  62. iatoolkit/views/init_context_api_view.py +1 -1
  63. iatoolkit/views/llmquery_api_view.py +4 -3
  64. iatoolkit/views/{file_store_api_view.py → load_document_api_view.py} +1 -1
  65. iatoolkit/views/login_view.py +17 -5
  66. iatoolkit/views/logout_api_view.py +10 -2
  67. iatoolkit/views/prompt_api_view.py +1 -1
  68. iatoolkit/views/root_redirect_view.py +22 -0
  69. iatoolkit/views/signup_view.py +12 -4
  70. iatoolkit/views/static_page_view.py +27 -0
  71. iatoolkit/views/verify_user_view.py +1 -1
  72. iatoolkit-0.91.1.dist-info/METADATA +268 -0
  73. iatoolkit-0.91.1.dist-info/RECORD +125 -0
  74. iatoolkit-0.91.1.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
  75. iatoolkit/services/history_service.py +0 -37
  76. iatoolkit/templates/about.html +0 -13
  77. iatoolkit/templates/index.html +0 -145
  78. iatoolkit/templates/login_simulation.html +0 -45
  79. iatoolkit/views/external_login_view.py +0 -73
  80. iatoolkit/views/index_view.py +0 -14
  81. iatoolkit/views/login_simulation_view.py +0 -93
  82. iatoolkit-0.71.4.dist-info/METADATA +0 -276
  83. iatoolkit-0.71.4.dist-info/RECORD +0 -122
  84. {iatoolkit-0.71.4.dist-info → iatoolkit-0.91.1.dist-info}/WHEEL +0 -0
  85. {iatoolkit-0.71.4.dist-info → iatoolkit-0.91.1.dist-info}/licenses/LICENSE +0 -0
  86. {iatoolkit-0.71.4.dist-info → iatoolkit-0.91.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,231 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from injector import inject
7
+ from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
8
+ from iatoolkit.repositories.models import Company, Tool
9
+ from iatoolkit.common.exceptions import IAToolkitException
10
+ from iatoolkit.services.sql_service import SqlService
11
+ from iatoolkit.services.excel_service import ExcelService
12
+ from iatoolkit.services.mail_service import MailService
13
+
14
+
15
+ _SYSTEM_TOOLS = [
16
+ {
17
+ "function_name": "iat_generate_excel",
18
+ "description": "Generador de Excel."
19
+ "Genera un archivo Excel (.xlsx) a partir de una lista de diccionarios. "
20
+ "Cada diccionario representa una fila del archivo. "
21
+ "el archivo se guarda en directorio de descargas."
22
+ "retorna diccionario con filename, attachment_token (para enviar archivo por mail)"
23
+ "content_type y download_link",
24
+ "parameters": {
25
+ "type": "object",
26
+ "properties": {
27
+ "filename": {
28
+ "type": "string",
29
+ "description": "Nombre del archivo de salida (ejemplo: 'reporte.xlsx')",
30
+ "pattern": "^.+\\.xlsx?$"
31
+ },
32
+ "sheet_name": {
33
+ "type": "string",
34
+ "description": "Nombre de la hoja dentro del Excel",
35
+ "minLength": 1
36
+ },
37
+ "data": {
38
+ "type": "array",
39
+ "description": "Lista de diccionarios. Cada diccionario representa una fila.",
40
+ "minItems": 1,
41
+ "items": {
42
+ "type": "object",
43
+ "properties": {},
44
+ "additionalProperties": {
45
+ "anyOf": [
46
+ {"type": "string"},
47
+ {"type": "number"},
48
+ {"type": "boolean"},
49
+ {"type": "null"},
50
+ {
51
+ "type": "string",
52
+ "format": "date"
53
+ }
54
+ ]
55
+ }
56
+ }
57
+ }
58
+ },
59
+ "required": ["filename", "sheet_name", "data"]
60
+ }
61
+ },
62
+ {
63
+ 'function_name': "iat_send_email",
64
+ 'description': "iatoolkit mail system. "
65
+ "envia mails cuando un usuario lo solicita.",
66
+ 'parameters': {
67
+ "type": "object",
68
+ "properties": {
69
+ "recipient": {"type": "string", "description": "email del destinatario"},
70
+ "subject": {"type": "string", "description": "asunto del email"},
71
+ "body": {"type": "string", "description": "HTML del email"},
72
+ "attachments": {
73
+ "type": "array",
74
+ "description": "Lista de archivos adjuntos codificados en base64",
75
+ "items": {
76
+ "type": "object",
77
+ "properties": {
78
+ "filename": {
79
+ "type": "string",
80
+ "description": "Nombre del archivo con su extensión (ej. informe.pdf)"
81
+ },
82
+ "content": {
83
+ "type": "string",
84
+ "description": "Contenido del archivo en b64."
85
+ },
86
+ "attachment_token": {
87
+ "type": "string",
88
+ "description": "token para descargar el archivo."
89
+ }
90
+ },
91
+ "required": ["filename", "content", "attachment_token"],
92
+ "additionalProperties": False
93
+ }
94
+ }
95
+ },
96
+ "required": ["recipient", "subject", "body", "attachments"]
97
+ }
98
+ },
99
+ {
100
+ "function_name": "iat_sql_query",
101
+ "description": "Servicio SQL de IAToolkit: debes utilizar este servicio para todas las consultas a base de datos.",
102
+ "parameters": {
103
+ "type": "object",
104
+ "properties": {
105
+ "database": {
106
+ "type": "string",
107
+ "description": "nombre de la base de datos a consultar: `database_name`"
108
+ },
109
+ "query": {
110
+ "type": "string",
111
+ "description": "string con la consulta en sql"
112
+ },
113
+ },
114
+ "required": ["database", "query"]
115
+ }
116
+ }
117
+ ]
118
+
119
+
120
+ class ToolService:
121
+ @inject
122
+ def __init__(self,
123
+ llm_query_repo: LLMQueryRepo,
124
+ sql_service: SqlService,
125
+ excel_service: ExcelService,
126
+ mail_service: MailService):
127
+ self.llm_query_repo = llm_query_repo
128
+ self.sql_service = sql_service
129
+ self.excel_service = excel_service
130
+ self.mail_service = mail_service
131
+
132
+ # execution mapper for system tools
133
+ self.system_handlers = {
134
+ "iat_generate_excel": self.excel_service.excel_generator,
135
+ "iat_send_email": self.mail_service.send_mail,
136
+ "iat_sql_query": self.sql_service.exec_sql
137
+ }
138
+
139
+ def register_system_tools(self):
140
+ """Creates or updates system functions in the database."""
141
+ try:
142
+ # delete all system tools
143
+ self.llm_query_repo.delete_system_tools()
144
+
145
+ # create new system tools
146
+ for function in _SYSTEM_TOOLS:
147
+ new_tool = Tool(
148
+ company_id=None,
149
+ system_function=True,
150
+ name=function['function_name'],
151
+ description=function['description'],
152
+ parameters=function['parameters']
153
+ )
154
+ self.llm_query_repo.create_or_update_tool(new_tool)
155
+
156
+ self.llm_query_repo.commit()
157
+ except Exception as e:
158
+ self.llm_query_repo.rollback()
159
+ raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR, str(e))
160
+
161
+ def sync_company_tools(self, company_instance, tools_config: list):
162
+ """
163
+ Synchronizes tools from YAML config to Database (Create/Update/Delete strategy).
164
+ """
165
+ try:
166
+ # 1. Get existing tools map for later cleanup
167
+ existing_tools = {
168
+ f.name: f for f in self.llm_query_repo.get_company_tools(company_instance.company)
169
+ }
170
+ defined_tool_names = set()
171
+
172
+ # 2. Sync (Create or Update) from Config
173
+ for tool_data in tools_config:
174
+ name = tool_data['function_name']
175
+ defined_tool_names.add(name)
176
+
177
+ # Construct the tool object with current config values
178
+ # We create a new transient object and let the repo merge it
179
+ tool_obj = Tool(
180
+ company_id=company_instance.company.id,
181
+ name=name,
182
+ description=tool_data['description'],
183
+ parameters=tool_data['params'],
184
+ system_function=False
185
+ )
186
+
187
+ # Always call create_or_update. The repo handles checking for existence by name.
188
+ self.llm_query_repo.create_or_update_tool(tool_obj)
189
+
190
+ # 3. Cleanup: Delete tools present in DB but not in Config
191
+ for name, tool in existing_tools.items():
192
+ # Ensure we don't delete system functions or active tools accidentally if logic changes,
193
+ # though get_company_tools filters by company_id so system functions shouldn't be here usually.
194
+ if not tool.system_function and (name not in defined_tool_names):
195
+ self.llm_query_repo.delete_tool(tool)
196
+
197
+ self.llm_query_repo.commit()
198
+
199
+ except Exception as e:
200
+ self.llm_query_repo.rollback()
201
+ raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR, str(e))
202
+
203
+
204
+ def get_tools_for_llm(self, company: Company) -> list[dict]:
205
+ """
206
+ Returns the list of tools (System + Company) formatted for the LLM (OpenAI Schema).
207
+ """
208
+ tools = []
209
+ # Obtiene tanto las de la empresa como las del sistema (la query del repo debería soportar esto con OR)
210
+ functions = self.llm_query_repo.get_company_tools(company)
211
+
212
+ for function in functions:
213
+ # Clonamos para no modificar el objeto de la sesión SQLAlchemy
214
+ params = function.parameters.copy() if function.parameters else {}
215
+ params["additionalProperties"] = False
216
+
217
+ ai_tool = {
218
+ "type": "function",
219
+ "name": function.name,
220
+ "description": function.description,
221
+ "parameters": params,
222
+ "strict": True
223
+ }
224
+ tools.append(ai_tool)
225
+ return tools
226
+
227
+ def get_system_handler(self, function_name: str):
228
+ return self.system_handlers.get(function_name)
229
+
230
+ def is_system_tool(self, function_name: str) -> bool:
231
+ return function_name in self.system_handlers
@@ -8,7 +8,7 @@ from injector import inject
8
8
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
9
  from iatoolkit.services.i18n_service import I18nService
10
10
  from iatoolkit.infra.google_chat_app import GoogleChatApp
11
- from iatoolkit.infra.mail_app import MailApp
11
+ from iatoolkit.services.mail_service import MailService
12
12
  import logging
13
13
 
14
14
 
@@ -18,11 +18,11 @@ class UserFeedbackService:
18
18
  profile_repo: ProfileRepo,
19
19
  i18n_service: I18nService,
20
20
  google_chat_app: GoogleChatApp,
21
- mail_app: MailApp):
21
+ mail_service: MailService):
22
22
  self.profile_repo = profile_repo
23
23
  self.i18n_service = i18n_service
24
24
  self.google_chat_app = google_chat_app
25
- self.mail_app = mail_app
25
+ self.mail_service = mail_service
26
26
 
27
27
  def _send_google_chat_notification(self, space_name: str, message_text: str):
28
28
  """Envía una notificación de feedback a un espacio de Google Chat."""
@@ -38,13 +38,21 @@ class UserFeedbackService:
38
38
  except Exception as e:
39
39
  logging.exception(f"error sending notification to Google Chat: {e}")
40
40
 
41
- def _send_email_notification(self, destination_email: str, company_name: str, message_text: str):
41
+ def _send_email_notification(self,
42
+ company_short_name: str,
43
+ destination_email: str,
44
+ company_name: str,
45
+ message_text: str):
42
46
  """Envía una notificación de feedback por correo electrónico."""
43
47
  try:
44
48
  subject = f"Nuevo Feedback de {company_name}"
45
49
  # Convertir el texto plano a un HTML simple para mantener los saltos de línea
46
50
  html_body = message_text.replace('\n', '<br>')
47
- self.mail_app.send_email(to=destination_email, subject=subject, body=html_body)
51
+ self.mail_service.send_mail(
52
+ company_short_name=company_short_name,
53
+ to=destination_email,
54
+ subject=subject,
55
+ body=html_body)
48
56
  except Exception as e:
49
57
  logging.exception(f"error sending email de feedback: {e}")
50
58
 
@@ -65,7 +73,11 @@ class UserFeedbackService:
65
73
  if channel == 'google_chat':
66
74
  self._send_google_chat_notification(space_name=destination, message_text=message_text)
67
75
  elif channel == 'email':
68
- self._send_email_notification(destination_email=destination, company_name=company.short_name, message_text=message_text)
76
+ self._send_email_notification(
77
+ company_short_name=company.short_name,
78
+ destination_email=destination,
79
+ company_name=company.short_name,
80
+ message_text=message_text)
69
81
  else:
70
82
  logging.warning(f"unknown feedback channel: '{channel}' for company {company.short_name}.")
71
83
 
@@ -49,6 +49,24 @@ class UserSessionContextService:
49
49
  if session_key:
50
50
  RedisSessionManager.hset(session_key, 'last_response_id', response_id)
51
51
 
52
+ def get_initial_response_id(self, company_short_name: str, user_identifier: str) -> Optional[str]:
53
+ """
54
+ Obtiene el ID de respuesta inicial desde la sesión del usuario.
55
+ Este ID corresponde al estado del LLM justo después de haber configurado el contexto.
56
+ """
57
+ session_key = self._get_session_key(company_short_name, user_identifier)
58
+ if not session_key:
59
+ return None
60
+ return RedisSessionManager.hget(session_key, 'initial_response_id')
61
+
62
+ def save_initial_response_id(self, company_short_name: str, user_identifier: str, response_id: str):
63
+ """
64
+ Guarda el ID de respuesta inicial en la sesión del usuario.
65
+ """
66
+ session_key = self._get_session_key(company_short_name, user_identifier)
67
+ if session_key:
68
+ RedisSessionManager.hset(session_key, 'initial_response_id', response_id)
69
+
52
70
  def save_context_history(self, company_short_name: str, user_identifier: str, context_history: List[Dict]):
53
71
  session_key = self._get_session_key(company_short_name, user_identifier)
54
72
  if session_key:
@@ -53,7 +53,7 @@ $('#feedbackModal').on('hidden.bs.modal', function () {
53
53
  $('.star').removeClass('active');
54
54
  });
55
55
 
56
- // Function for the star rating system
56
+ // Tool for the star rating system
57
57
  window.gfg = function (rating) {
58
58
  $('.star').removeClass('active');
59
59
  $('.star').each(function (index) {
@@ -59,7 +59,7 @@ $(document).ready(function () {
59
59
  cat.questions.forEach(q => contentHtml += `<li>${q}</li>`);
60
60
  contentHtml += `</ul>`;
61
61
  });
62
- accordionHtml += createAccordionItem('examples', 'Preguntas de Ejemplo', contentHtml, true);
62
+ accordionHtml += createAccordionItem('examples', 'Sample questions', contentHtml, true);
63
63
  }
64
64
 
65
65
  if (data.data_sources) {
@@ -68,7 +68,7 @@ $(document).ready(function () {
68
68
  contentHtml += `<dt>${p.source}</dt><dd>${p.description}</dd>`;
69
69
  });
70
70
  contentHtml += `</dl>`;
71
- accordionHtml += createAccordionItem('sources', 'Datos disponibles', contentHtml );
71
+ accordionHtml += createAccordionItem('sources', 'Data available', contentHtml );
72
72
  }
73
73
 
74
74
  if (data.best_practices) {
@@ -81,7 +81,7 @@ $(document).ready(function () {
81
81
  contentHtml += `</dd>`;
82
82
  });
83
83
  contentHtml += `</dl>`;
84
- accordionHtml += createAccordionItem('practices', 'Mejores Prácticas', contentHtml);
84
+ accordionHtml += createAccordionItem('practices', 'Best practices', contentHtml);
85
85
  }
86
86
 
87
87
  if (data.capabilities) {
@@ -89,7 +89,7 @@ $(document).ready(function () {
89
89
  contentHtml += `<div class="col-md-6"><h6 class="fw-bold">Puede hacer:</h6><ul>${data.capabilities.can_do.map(item => `<li>${item}</li>`).join('')}</ul></div>`;
90
90
  contentHtml += `<div class="col-md-6"><h6 class="fw-bold">No puede hacer:</h6><ul>${data.capabilities.cannot_do.map(item => `<li>${item}</li>`).join('')}</ul></div>`;
91
91
  contentHtml += `</div>`;
92
- accordionHtml += createAccordionItem('capabilities', 'Capacidades y Límites', contentHtml);
92
+ accordionHtml += createAccordionItem('capabilities', 'Capabilities and limits', contentHtml);
93
93
  }
94
94
 
95
95
  container.html(accordionHtml);
@@ -120,7 +120,6 @@ const handleChatMessage = async function () {
120
120
  }
121
121
  } catch (error) {
122
122
  if (error.name === 'AbortError') {
123
- console.log('Petición abortada por el usuario.');
124
123
 
125
124
  // Usando jQuery estándar para construir el elemento ---
126
125
  const icon = $('<i>').addClass('bi bi-stop-circle me-2'); // Icono sin "fill" para un look más ligero
@@ -230,22 +229,35 @@ const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
230
229
  }
231
230
  const response = await fetch(url, fetchOptions);
232
231
  clearTimeout(timeoutId);
232
+
233
+ // answer is NOT OK (status != 200)
233
234
  if (!response.ok) {
234
235
  try {
235
236
  // Intentamos leer el error como JSON, que es el formato esperado de nuestra API.
236
237
  const errorData = await response.json();
237
- const errorMessage = errorData.error_message || t_js('unknown_server_error'); // <-- Translation
238
- const errorIcon = '<i class="bi bi-exclamation-triangle"></i>';
239
- const endpointError = $('<div>').addClass('error-section').html(errorIcon + `<p>${errorMessage}</p>`);
240
- displayBotMessage(endpointError);
238
+
239
+ // if it's a iatoolkit error (409 o 400 with a message), shot it on the chat
240
+ if (errorData && (errorData.error_message || errorData.error)) {
241
+ const errorMessage = errorData.error_message || errorData.error || t_js('unknown_server_error');
242
+ const errorIcon = '<i class="bi bi-exclamation-triangle"></i>';
243
+ const endpointError = $('<div>').addClass('error-section').html(errorIcon + `<p>${errorMessage}</p>`);
244
+ displayBotMessage(endpointError);
245
+ } else {
246
+ // if there is not message, we show a generic error message
247
+ throw new Error(`Server error: ${response.status}`);
248
+ }
241
249
  } catch (e) {
242
250
  // Si response.json() falla, es porque el cuerpo no era JSON (ej. un 502 con HTML).
243
251
  // Mostramos un error genérico y más claro para el usuario.
244
252
  const errorMessage = `Error de comunicación con el servidor (${response.status}). Por favor, intente de nuevo más tarde.`;
245
253
  toastr.error(errorMessage);
246
254
  }
255
+
256
+ // stop the flow on the calling function
247
257
  return null;
248
258
  }
259
+
260
+ // if the answer is OK
249
261
  return await response.json();
250
262
  } catch (error) {
251
263
  clearTimeout(timeoutId);
@@ -45,7 +45,7 @@
45
45
  if (elTitle) elTitle.textContent = c.title || '';
46
46
  if (elText) elText.innerHTML = c.text || '';
47
47
  if (elExample && c.example) {
48
- elExample.innerHTML = ('Ejemplo: ' + c.example) || '';
48
+ elExample.innerHTML = (t_js('example') + ': ' + c.example) || '';
49
49
  }
50
50
  else
51
51
  elExample.innerHTML = '';
@@ -172,7 +172,7 @@ li {
172
172
  max-width: 75%;
173
173
  min-width: 250px;
174
174
 
175
- color: var(--brand-danger-text, #842029); /* Color de texto de error de la marca */
175
+ color: var(--brand-danger-text, #000000); /* Color de texto de error de la marca */
176
176
  background-color: var(--brand-danger-bg, #f8d7da); /* Fondo de error de la marca */
177
177
  border: 1px solid var(--brand-danger-border, #f5c2c7); /* Borde de error de la marca */
178
178
  font-style: italic;
@@ -104,4 +104,32 @@
104
104
  margin-bottom: 1.5rem; /* Espacio consistente debajo del título */
105
105
  }
106
106
 
107
+ /* Links de idioma sobre fondo azul */
108
+ .language-link {
109
+ text-decoration: none;
110
+ color: #ffffff; /* texto blanco */
111
+ padding: 1px 4px;
112
+ font-size: 0.85rem;
113
+ font-weight: 500;
114
+ border-radius: 3px;
115
+ transition: background-color 0.2s ease;
116
+ }
117
+
118
+ /* Separador | puede heredar color blanco */
119
+ .language-switcher .mx-1 {
120
+ color: #ffffff;
121
+ }
122
+
123
+ /* Hover: blanco translúcido encima del azul */
124
+ .language-link:hover {
125
+ background-color: rgba(255, 255, 255, 0.25);
126
+ }
127
+
128
+ /* Idioma activo: un poco más marcado */
129
+ .language-link.active {
130
+ background-color: rgba(255, 255, 255, 0.35);
131
+ font-weight: 700;
132
+ color: #ffffff;
133
+ }
134
+
107
135