iatoolkit 0.63.1__py3-none-any.whl → 0.69.0__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 (83) hide show
  1. iatoolkit/__init__.py +0 -2
  2. iatoolkit/base_company.py +1 -26
  3. iatoolkit/common/routes.py +11 -2
  4. iatoolkit/common/session_manager.py +2 -0
  5. iatoolkit/common/util.py +17 -0
  6. iatoolkit/company_registry.py +1 -2
  7. iatoolkit/iatoolkit.py +39 -6
  8. iatoolkit/locales/en.yaml +167 -0
  9. iatoolkit/locales/es.yaml +163 -0
  10. iatoolkit/repositories/database_manager.py +8 -3
  11. iatoolkit/repositories/document_repo.py +1 -1
  12. iatoolkit/repositories/models.py +1 -4
  13. iatoolkit/repositories/profile_repo.py +0 -4
  14. iatoolkit/services/auth_service.py +14 -9
  15. iatoolkit/services/branding_service.py +36 -24
  16. iatoolkit/services/company_context_service.py +145 -0
  17. iatoolkit/services/configuration_service.py +133 -0
  18. iatoolkit/services/dispatcher_service.py +51 -48
  19. iatoolkit/services/document_service.py +5 -2
  20. iatoolkit/services/excel_service.py +15 -11
  21. iatoolkit/services/file_processor_service.py +4 -12
  22. iatoolkit/services/history_service.py +8 -7
  23. iatoolkit/services/i18n_service.py +104 -0
  24. iatoolkit/services/jwt_service.py +7 -9
  25. iatoolkit/services/language_service.py +83 -0
  26. iatoolkit/services/load_documents_service.py +4 -4
  27. iatoolkit/services/mail_service.py +9 -4
  28. iatoolkit/services/profile_service.py +61 -38
  29. iatoolkit/services/prompt_manager_service.py +20 -16
  30. iatoolkit/services/query_service.py +19 -15
  31. iatoolkit/services/search_service.py +11 -4
  32. iatoolkit/services/sql_service.py +55 -25
  33. iatoolkit/services/user_feedback_service.py +16 -14
  34. iatoolkit/static/js/chat_feedback_button.js +57 -87
  35. iatoolkit/static/js/chat_help_content.js +124 -0
  36. iatoolkit/static/js/chat_history_button.js +48 -65
  37. iatoolkit/static/js/chat_main.js +27 -24
  38. iatoolkit/static/js/chat_onboarding_button.js +6 -0
  39. iatoolkit/static/js/chat_reload_button.js +28 -45
  40. iatoolkit/static/styles/chat_iatoolkit.css +223 -315
  41. iatoolkit/static/styles/chat_modal.css +63 -97
  42. iatoolkit/static/styles/chat_public.css +107 -0
  43. iatoolkit/static/styles/landing_page.css +0 -1
  44. iatoolkit/static/styles/onboarding.css +7 -0
  45. iatoolkit/templates/_company_header.html +6 -2
  46. iatoolkit/templates/_login_widget.html +42 -0
  47. iatoolkit/templates/base.html +34 -19
  48. iatoolkit/templates/change_password.html +22 -20
  49. iatoolkit/templates/chat.html +59 -27
  50. iatoolkit/templates/chat_modals.html +114 -74
  51. iatoolkit/templates/error.html +12 -13
  52. iatoolkit/templates/forgot_password.html +11 -7
  53. iatoolkit/templates/index.html +8 -3
  54. iatoolkit/templates/login_simulation.html +17 -6
  55. iatoolkit/templates/onboarding_shell.html +4 -2
  56. iatoolkit/templates/signup.html +14 -14
  57. iatoolkit/views/base_login_view.py +19 -9
  58. iatoolkit/views/change_password_view.py +50 -35
  59. iatoolkit/views/external_login_view.py +1 -1
  60. iatoolkit/views/forgot_password_view.py +21 -22
  61. iatoolkit/views/help_content_api_view.py +54 -0
  62. iatoolkit/views/history_api_view.py +13 -9
  63. iatoolkit/views/home_view.py +30 -39
  64. iatoolkit/views/init_context_api_view.py +16 -11
  65. iatoolkit/views/llmquery_api_view.py +38 -26
  66. iatoolkit/views/login_simulation_view.py +14 -2
  67. iatoolkit/views/login_view.py +52 -40
  68. iatoolkit/views/logout_api_view.py +26 -22
  69. iatoolkit/views/profile_api_view.py +46 -0
  70. iatoolkit/views/prompt_api_view.py +6 -6
  71. iatoolkit/views/signup_view.py +27 -27
  72. iatoolkit/views/user_feedback_api_view.py +19 -18
  73. iatoolkit/views/verify_user_view.py +29 -30
  74. {iatoolkit-0.63.1.dist-info → iatoolkit-0.69.0.dist-info}/METADATA +40 -22
  75. iatoolkit-0.69.0.dist-info/RECORD +120 -0
  76. iatoolkit-0.69.0.dist-info/licenses/LICENSE +21 -0
  77. iatoolkit/services/onboarding_service.py +0 -43
  78. iatoolkit/static/styles/chat_info.css +0 -53
  79. iatoolkit/templates/header.html +0 -31
  80. iatoolkit/templates/test.html +0 -9
  81. iatoolkit-0.63.1.dist-info/RECORD +0 -112
  82. {iatoolkit-0.63.1.dist-info → iatoolkit-0.69.0.dist-info}/WHEEL +0 -0
  83. {iatoolkit-0.63.1.dist-info → iatoolkit-0.69.0.dist-info}/top_level.txt +0 -0
@@ -5,8 +5,9 @@
5
5
 
6
6
  from iatoolkit.common.exceptions import IAToolkitException
7
7
  from iatoolkit.services.prompt_manager_service import PromptService
8
+ from iatoolkit.services.sql_service import SqlService
8
9
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
9
-
10
+ from iatoolkit.services.configuration_service import ConfigurationService
10
11
  from iatoolkit.repositories.models import Company, Function
11
12
  from iatoolkit.services.excel_service import ExcelService
12
13
  from iatoolkit.services.mail_service import MailService
@@ -19,14 +20,18 @@ import os
19
20
  class Dispatcher:
20
21
  @inject
21
22
  def __init__(self,
23
+ config_service: ConfigurationService,
22
24
  prompt_service: PromptService,
23
25
  llmquery_repo: LLMQueryRepo,
24
26
  util: Utility,
27
+ sql_service: SqlService,
25
28
  excel_service: ExcelService,
26
29
  mail_service: MailService):
30
+ self.config_service = config_service
27
31
  self.prompt_service = prompt_service
28
32
  self.llmquery_repo = llmquery_repo
29
33
  self.util = util
34
+ self.sql_service = sql_service
30
35
  self.excel_service = excel_service
31
36
  self.mail_service = mail_service
32
37
  self.system_functions = _FUNCTION_LIST
@@ -55,17 +60,50 @@ class Dispatcher:
55
60
  self._company_instances = self.company_registry.get_all_company_instances()
56
61
  return self._company_instances
57
62
 
58
- def start_execution(self):
63
+ def load_company_configs(self):
59
64
  # initialize the system functions and prompts
60
65
  self.setup_iatoolkit_system()
61
66
 
62
- """Runs the startup logic for all registered companies."""
63
- for company in self.company_instances.values():
64
- company.register_company()
65
- company.start_execution()
67
+ """Loads the configuration of every company"""
68
+ for company_name, company_instance in self.company_instances.items():
69
+ try:
70
+ # read company configuration from company.yaml
71
+ self.config_service.load_configuration(company_name, company_instance)
72
+
73
+ # register the company databases
74
+ self._register_company_databases(company_name)
75
+
76
+ except Exception as e:
77
+ logging.error(f"❌ Failed to register configuration for '{company_name}': {e}")
78
+ continue
66
79
 
67
80
  return True
68
81
 
82
+ def _register_company_databases(self, company_name: str):
83
+ """
84
+ Reads the data_sources config for a company and registers each
85
+ database with the central SqlService.
86
+ """
87
+ logging.info(f" -> Registering databases for '{company_name}'...")
88
+ data_sources_config = self.config_service.get_configuration(company_name, 'data_sources')
89
+
90
+ if not data_sources_config or not data_sources_config.get('sql'):
91
+ logging.info(f" -> No SQL data sources to register for '{company_name}'.")
92
+ return
93
+
94
+ for db_config in data_sources_config['sql']:
95
+ db_name = db_config.get('database')
96
+ db_env_var = db_config.get('connection_string_env')
97
+
98
+ # resolve the URI connection string from the environment variable
99
+ db_uri = os.getenv(db_env_var) if db_env_var else None
100
+ if not db_uri:
101
+ logging.warning(
102
+ f"-> Skipping database registration for '{company_name}' due to missing 'database' name or connection URI.")
103
+ return
104
+
105
+ self.sql_service.register_database(db_name, db_uri)
106
+
69
107
  def setup_iatoolkit_system(self):
70
108
  # create system functions
71
109
  for function in self.system_functions:
@@ -90,9 +128,6 @@ class Dispatcher:
90
128
  )
91
129
  i += 1
92
130
 
93
- # register in the database every company class
94
- for company in self.company_instances.values():
95
- company.register_company()
96
131
 
97
132
  def dispatch(self, company_name: str, action: str, **kwargs) -> dict:
98
133
  company_key = company_name.lower()
@@ -120,37 +155,6 @@ class Dispatcher:
120
155
  raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
121
156
  f"Error en function call '{action}': {str(e)}") from e
122
157
 
123
- def get_company_context(self, company_name: str, **kwargs) -> str:
124
- if company_name not in self.company_instances:
125
- raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
126
- f"Empresa no configurada: {company_name}")
127
-
128
- company_context = ''
129
-
130
- # read the company context from this list of markdown files,
131
- # company brief, credits, operation description, etc.
132
- context_dir = os.path.join(os.getcwd(), f'companies/{company_name}/context')
133
- context_files = self.util.get_files_by_extension(context_dir, '.md', return_extension=True)
134
- for file in context_files:
135
- filepath = os.path.join(context_dir, file)
136
- company_context += self.util.load_markdown_context(filepath)
137
-
138
- # add the schemas for every table or function call responses
139
- schema_dir = os.path.join(os.getcwd(), f'companies/{company_name}/schema')
140
- schema_files = self.util.get_files_by_extension(schema_dir, '.yaml', return_extension=True)
141
- for file in schema_files:
142
- schema_name = file.split('_')[0]
143
- filepath = os.path.join(schema_dir, file)
144
- company_context += self.util.generate_context_for_schema(schema_name, filepath)
145
-
146
- company_instance = self.company_instances[company_name]
147
- try:
148
- return company_context + company_instance.get_company_context(**kwargs)
149
- except Exception as e:
150
- logging.exception(e)
151
- raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
152
- f"Error en get_company_context de {company_name}: {str(e)}") from e
153
-
154
158
  def get_company_services(self, company: Company) -> list[dict]:
155
159
  # create the syntax with openai response syntax, for the company function list
156
160
  tools = []
@@ -173,7 +177,7 @@ class Dispatcher:
173
177
  def get_user_info(self, company_name: str, user_identifier: str) -> dict:
174
178
  if company_name not in self.company_instances:
175
179
  raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
176
- f"Empresa no configurada: {company_name}")
180
+ f"company not configured: {company_name}")
177
181
 
178
182
  # source 2: external company user
179
183
  company_instance = self.company_instances[company_name]
@@ -182,14 +186,14 @@ class Dispatcher:
182
186
  except Exception as e:
183
187
  logging.exception(e)
184
188
  raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
185
- f"Error en get_user_info de {company_name}: {str(e)}") from e
189
+ f"Error in get_user_info: {company_name}: {str(e)}") from e
186
190
 
187
191
  return external_user_profile
188
192
 
189
193
  def get_metadata_from_filename(self, company_name: str, filename: str) -> dict:
190
194
  if company_name not in self.company_instances:
191
195
  raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
192
- f"Empresa no configurada: {company_name}")
196
+ f"company not configured: {company_name}")
193
197
 
194
198
  company_instance = self.company_instances[company_name]
195
199
  try:
@@ -197,7 +201,7 @@ class Dispatcher:
197
201
  except Exception as e:
198
202
  logging.exception(e)
199
203
  raise IAToolkitException(IAToolkitException.ErrorType.EXTERNAL_SOURCE_ERROR,
200
- f"Error en get_metadata_from_filename de {company_name}: {str(e)}") from e
204
+ f"Error in get_metadata_from_filename: {company_name}: {str(e)}") from e
201
205
 
202
206
  def get_company_instance(self, company_name: str):
203
207
  """Returns the instance for a given company name."""
@@ -207,12 +211,11 @@ class Dispatcher:
207
211
 
208
212
  # iatoolkit system prompts
209
213
  _SYSTEM_PROMPT = [
210
- {'name': 'query_main', 'description':'main prompt de iatoolkit'},
211
- {'name': 'format_styles', 'description':'formatos y estilos de salida'},
212
- {'name': 'sql_rules', 'description':'instrucciones para generar sql'}
214
+ {'name': 'query_main', 'description':'iatoolkit main prompt'},
215
+ {'name': 'format_styles', 'description':'output format styles'},
216
+ {'name': 'sql_rules', 'description':'instructions for SQL queries'}
213
217
  ]
214
218
 
215
-
216
219
  # iatoolkit function calls
217
220
  _FUNCTION_LIST = [
218
221
  {
@@ -11,10 +11,13 @@ import os
11
11
  import pytesseract
12
12
  from injector import inject
13
13
  from iatoolkit.common.exceptions import IAToolkitException
14
+ from iatoolkit.services.i18n_service import I18nService
14
15
 
15
16
  class DocumentService:
16
17
  @inject
17
- def __init__(self):
18
+ def __init__(self, i18n_service: I18nService):
19
+ self.i18n_service = i18n_service
20
+
18
21
  # max number of pages to load
19
22
  self.max_doc_pages = int(os.getenv("MAX_DOC_PAGES", "200"))
20
23
 
@@ -29,7 +32,7 @@ class DocumentService:
29
32
  file_content = file_content.decode('utf-8')
30
33
  except UnicodeDecodeError:
31
34
  raise IAToolkitException(IAToolkitException.ErrorType.FILE_FORMAT_ERROR,
32
- "El archivo no es texto o la codificación no es UTF-8")
35
+ self.i18n_service.t('errors.services.no_text_file'))
33
36
 
34
37
  return file_content
35
38
  elif filename.lower().endswith('.pdf'):
@@ -8,6 +8,7 @@ import pandas as pd
8
8
  from uuid import uuid4
9
9
  from pathlib import Path
10
10
  from iatoolkit.common.exceptions import IAToolkitException
11
+ from iatoolkit.services.i18n_service import I18nService
11
12
  from injector import inject
12
13
  import os
13
14
  import logging
@@ -18,8 +19,11 @@ EXCEL_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
18
19
 
19
20
  class ExcelService:
20
21
  @inject
21
- def __init__(self,util: Utility):
22
+ def __init__(self,
23
+ util: Utility,
24
+ i18n_service: I18nService):
22
25
  self.util = util
26
+ self.i18n_service = i18n_service
23
27
 
24
28
  def excel_generator(self, **kwargs) -> str:
25
29
  """
@@ -42,11 +46,11 @@ class ExcelService:
42
46
  # get the parameters
43
47
  fname = kwargs.get('filename')
44
48
  if not fname:
45
- return 'falta el nombre del archivo de salida'
49
+ return self.i18n_service.t('errors.services.no_output_file')
46
50
 
47
51
  data = kwargs.get('data')
48
52
  if not data or not isinstance(data, list):
49
- return 'faltan los datos o no es una lista de diccionarios'
53
+ return self.i18n_service.t('errors.services.no_data_for_excel')
50
54
 
51
55
  sheet_name = kwargs.get('sheet_name', 'hoja 1')
52
56
 
@@ -58,7 +62,7 @@ class ExcelService:
58
62
 
59
63
  # 4. check that download directory is configured
60
64
  if 'IATOOLKIT_DOWNLOAD_DIR' not in current_app.config:
61
- return 'no esta configurado el directorio temporal para guardar excels'
65
+ return self.i18n_service.t('errors.services.no_download_directory')
62
66
 
63
67
  download_dir = current_app.config['IATOOLKIT_DOWNLOAD_DIR']
64
68
  filepath = Path(download_dir) / token
@@ -77,28 +81,28 @@ class ExcelService:
77
81
 
78
82
  except Exception as e:
79
83
  raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR,
80
- 'error generating excel file') from e
84
+ self.i18n_service.t('errors.services.cannot_create_excel')) from e
81
85
 
82
86
  def validate_file_access(self, filename):
83
87
  try:
84
88
  if not filename:
85
- return jsonify({"error": "Nombre de archivo inválido"})
89
+ return jsonify({"error": self.i18n_service.t('errors.services.invalid_filename')})
86
90
  # Prevent path traversal attacks
87
91
  if '..' in filename or filename.startswith('/') or '\\' in filename:
88
- return jsonify({"error": "Nombre de archivo inválido"})
92
+ return jsonify({"error": self.i18n_service.t('errors.services.invalid_filename')})
89
93
 
90
94
  temp_dir = os.path.join(current_app.root_path, 'static', 'temp')
91
95
  file_path = os.path.join(temp_dir, filename)
92
96
 
93
97
  if not os.path.exists(file_path):
94
- return jsonify({"error": "Archivo no encontrado"})
98
+ return jsonify({"error": self.i18n_service.t('errors.services.file_not_exist')})
95
99
 
96
100
  if not os.path.isfile(file_path):
97
- return jsonify({"error": "La ruta no corresponde a un archivo"})
101
+ return jsonify({"error": self.i18n_service.t('errors.services.path_is_not_a_file')})
98
102
 
99
103
  return None
100
104
 
101
105
  except Exception as e:
102
- error_msg = f"Error validando acceso al archivo {filename}: {str(e)}"
106
+ error_msg = f"File validation error {filename}: {str(e)}"
103
107
  logging.error(error_msg)
104
- return jsonify({"error": "Error validando archivo"})
108
+ return jsonify({"error": self.i18n_service.t('errors.services.file_validation_error')})
@@ -52,27 +52,19 @@ class FileProcessor:
52
52
  logger: Optional[logging.Logger] = None):
53
53
  self.connector = connector
54
54
  self.config = config
55
- self.logger = logger or self._setup_logger()
56
55
  self.processed_files = 0
57
56
 
58
- def _setup_logger(self):
59
- logging.basicConfig(
60
- filename=self.config.log_file,
61
- level=logging.INFO,
62
- format='%(asctime)s - %(levelname)s - %(message)s'
63
- )
64
- return logging.getLogger(__name__)
65
57
 
66
58
  def process_files(self):
67
59
  # Fetches files from the connector, filters them, and processes them.
68
60
  try:
69
61
  files = self.connector.list_files()
70
62
  except Exception as e:
71
- self.logger.error(f"Error fetching files: {e}")
63
+ logging.error(f"Error fetching files: {e}")
72
64
  return False
73
65
 
74
66
  if self.config.echo:
75
- print(f'cargando un total de {len(files)} archivos')
67
+ print(f'loading {len(files)} files')
76
68
 
77
69
  for file_info in files:
78
70
  file_path = file_info['path']
@@ -95,10 +87,10 @@ class FileProcessor:
95
87
  context=self.config.context)
96
88
  self.processed_files += 1
97
89
 
98
- self.logger.info(f"Successfully processed file: {file_path}")
90
+ logging.info(f"Successfully processed file: {file_path}")
99
91
 
100
92
  except Exception as e:
101
- self.logger.error(f"Error processing {file_path}: {e}")
93
+ logging.error(f"Error processing {file_path}: {e}")
102
94
  if not self.config.continue_on_error:
103
95
  raise e
104
96
 
@@ -6,32 +6,33 @@
6
6
  from injector import inject
7
7
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
8
8
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
+ from iatoolkit.services.i18n_service import I18nService
10
+
9
11
 
10
12
 
11
13
  class HistoryService:
12
14
  @inject
13
15
  def __init__(self, llm_query_repo: LLMQueryRepo,
14
- profile_repo: ProfileRepo):
16
+ profile_repo: ProfileRepo,
17
+ i18n_service: I18nService):
15
18
  self.llm_query_repo = llm_query_repo
16
19
  self.profile_repo = profile_repo
20
+ self.i18n_service = i18n_service
17
21
 
18
22
  def get_history(self,
19
23
  company_short_name: str,
20
24
  user_identifier: str) -> dict:
21
25
  try:
22
- # validate company
23
26
  company = self.profile_repo.get_company_by_short_name(company_short_name)
24
27
  if not company:
25
- return {'error': f'No existe la empresa: {company_short_name}'}
28
+ return {"error": self.i18n_service.t('errors.company_not_found', company_short_name=company_short_name)}
26
29
 
27
30
  history = self.llm_query_repo.get_history(company, user_identifier)
28
-
29
31
  if not history:
30
- return {'message': 'Historial vacio actualmente', 'history': []}
32
+ return {'message': 'empty history', 'history': []}
31
33
 
32
34
  history_list = [query.to_dict() for query in history]
33
-
34
- return {'message': 'Historial obtenido correctamente', 'history': history_list}
35
+ return {'message': 'history loaded ok', 'history': history_list}
35
36
 
36
37
  except Exception as e:
37
38
  return {'error': str(e)}
@@ -0,0 +1,104 @@
1
+ # iatoolkit/services/i18n_service.py
2
+ import os
3
+ import logging
4
+ from injector import inject, singleton
5
+ from iatoolkit.common.util import Utility
6
+ from iatoolkit.services.language_service import LanguageService
7
+
8
+ @singleton
9
+ class I18nService:
10
+ """
11
+ Servicio centralizado para manejar la internacionalización (i18n).
12
+ Carga todas las traducciones desde archivos YAML en memoria al iniciar.
13
+ """
14
+ FALLBACK_LANGUAGE = 'es'
15
+
16
+ @inject
17
+ def __init__(self, util: Utility, language_service: LanguageService):
18
+ self.util = util
19
+ self.language_service = language_service
20
+
21
+ self.translations = {}
22
+ self._load_translations()
23
+
24
+ def _load_translations(self):
25
+ """
26
+ Carga todos los archivos .yaml del directorio 'locales' en memoria.
27
+ """
28
+ locales_dir = os.path.join(os.path.dirname(__file__), '..', 'locales')
29
+ if not os.path.exists(locales_dir):
30
+ logging.error("Directory 'locales' not found.")
31
+ return
32
+
33
+ for filename in os.listdir(locales_dir):
34
+ if filename.endswith('.yaml'):
35
+ lang_code = filename.split('.')[0]
36
+ filepath = os.path.join(locales_dir, filename)
37
+ try:
38
+ self.translations[lang_code] = self.util.load_schema_from_yaml(filepath)
39
+ except Exception as e:
40
+ logging.error(f"Error while loading the translation file {filepath}: {e}")
41
+
42
+ def _get_nested_key(self, lang: str, key: str):
43
+ """
44
+ Obtiene un valor de un diccionario anidado usando una clave con puntos.
45
+ """
46
+ data = self.translations.get(lang, {})
47
+ keys = key.split('.')
48
+ for k in keys:
49
+ if isinstance(data, dict) and k in data:
50
+ data = data[k]
51
+ else:
52
+ return None
53
+ return data
54
+
55
+ def get_translation_block(self, key: str, lang: str = None) -> dict:
56
+ """
57
+ Gets a whole dictionary block from the translations.
58
+ Useful for passing a set of translations to JavaScript.
59
+ """
60
+ if lang is None:
61
+ lang = self.language_service.get_current_language()
62
+
63
+ # 1. Try to get the block in the requested language
64
+ block = self._get_nested_key(lang, key)
65
+
66
+ # 2. If not found, try the fallback language
67
+ if not isinstance(block, dict):
68
+ block = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
69
+
70
+ return block if isinstance(block, dict) else {}
71
+
72
+ def t(self, key: str, lang: str = None, **kwargs) -> str:
73
+ """
74
+ Gets the translation for a given key.
75
+ If 'lang' is provided, it's used. Otherwise, it's determined automatically.
76
+ """
77
+ # If no specific language is requested, determine it from the current context.
78
+ if lang is None:
79
+ lang = self.language_service.get_current_language()
80
+
81
+ # 1. Attempt to get the translation in the requested language
82
+ message = self._get_nested_key(lang, key)
83
+
84
+ # 2. If not found, try the fallback language
85
+ if message is None and lang != self.FALLBACK_LANGUAGE:
86
+ logging.warning(
87
+ f"Translation key '{key}' not found for language '{lang}'. Attempting fallback to '{self.FALLBACK_LANGUAGE}'.")
88
+ message = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
89
+
90
+ # 3. If still not found, return the key itself as a last resort
91
+ if message is None:
92
+ logging.error(
93
+ f"Translation key '{key}' not found, even in fallback '{self.FALLBACK_LANGUAGE}'.")
94
+ return key
95
+
96
+ # 4. If variables are provided, format the message
97
+ if kwargs:
98
+ try:
99
+ return message.format(**kwargs)
100
+ except KeyError as e:
101
+ logging.error(f"Error formatting key '{key}': missing variable {e} in arguments.")
102
+ return message
103
+
104
+ return message
@@ -20,8 +20,8 @@ class JWTService:
20
20
  self.secret_key = app.config['JWT_SECRET_KEY']
21
21
  self.algorithm = app.config['JWT_ALGORITHM']
22
22
  except KeyError as e:
23
- logging.error(f"Configuración JWT faltante en app.config: {e}. JWTService no funcionará correctamente.")
24
- raise RuntimeError(f"Configuración JWT esencial faltante: {e}")
23
+ logging.error(f"missing JWT configuration: {e}.")
24
+ raise RuntimeError(f"missing JWT configuration variables: {e}")
25
25
 
26
26
  def generate_chat_jwt(self,
27
27
  company_short_name: str,
@@ -58,25 +58,23 @@ class JWTService:
58
58
 
59
59
  # Validaciones adicionales
60
60
  if payload.get('type') != 'chat_session':
61
- logging.warning(f"Validación JWT fallida: tipo incorrecto '{payload.get('type')}'")
61
+ logging.warning(f"Invalid JWT type '{payload.get('type')}'")
62
62
  return None
63
63
 
64
64
  # user_identifier debe estar presente
65
65
  if not payload.get('user_identifier'):
66
- logging.warning(f"Validación JWT fallida: user_identifier ausente o vacío.")
66
+ logging.warning(f"missing user_identifier in JWT payload.")
67
67
  return None
68
68
 
69
69
  if not payload.get('company_short_name'):
70
- logging.warning(f"Validación JWT fallida: company_short_name ausente.")
70
+ logging.warning(f"missing company_short_name in JWT payload.")
71
71
  return None
72
72
 
73
- logging.debug(
74
- f"JWT validado exitosamente para company: {payload.get('company_short_name')}, user: {payload.get('external_user_id')}")
75
73
  return payload
76
74
 
77
75
  except jwt.InvalidTokenError as e:
78
- logging.warning(f"Validación JWT fallida: token inválido . Error: {e}")
76
+ logging.warning(f"Invalid JWT token:: {e}")
79
77
  return None
80
78
  except Exception as e:
81
- logging.error(f"Error inesperado durante validación de JWT : {e}")
79
+ logging.error(f"unexpected error during JWT validation: {e}")
82
80
  return None
@@ -0,0 +1,83 @@
1
+ # iatoolkit/services/language_service.py
2
+
3
+ import logging
4
+ from injector import inject, singleton
5
+ from flask import g, request
6
+ from iatoolkit.repositories.profile_repo import ProfileRepo
7
+ from iatoolkit.services.configuration_service import ConfigurationService
8
+ from iatoolkit.common.session_manager import SessionManager
9
+
10
+ @singleton
11
+ class LanguageService:
12
+ """
13
+ Determines the correct language for the current request
14
+ based on a defined priority order (session, URL, etc.)
15
+ and caches it in the Flask 'g' object for the request's lifecycle.
16
+ """
17
+
18
+ FALLBACK_LANGUAGE = 'es'
19
+
20
+ @inject
21
+ def __init__(self,
22
+ config_service: ConfigurationService,
23
+ profile_repo: ProfileRepo):
24
+ self.config_service = config_service
25
+ self.profile_repo = profile_repo
26
+
27
+ def _get_company_short_name(self) -> str | None:
28
+ """
29
+ Gets the company_short_name from the current request context.
30
+ This handles different scenarios like web sessions, public URLs, and API calls.
31
+
32
+ Priority Order:
33
+ 1. Flask Session (for logged-in web users).
34
+ 2. URL rule variable (for public pages and API endpoints).
35
+ """
36
+ # 1. Check session for logged-in users
37
+ company_short_name = SessionManager.get('company_short_name')
38
+ if company_short_name:
39
+ return company_short_name
40
+
41
+ # 2. Check URL arguments (e.g., /<company_short_name>/login)
42
+ # This covers public pages and most API calls.
43
+ if request.view_args and 'company_short_name' in request.view_args:
44
+ return request.view_args['company_short_name']
45
+
46
+ return None
47
+
48
+ def get_current_language(self) -> str:
49
+ """
50
+ Determines and caches the language for the current request using a priority order:
51
+ 1. User's preference (from their profile).
52
+ 2. Company's default language.
53
+ 3. System-wide fallback language ('es').
54
+ """
55
+ if 'lang' in g:
56
+ return g.lang
57
+
58
+ try:
59
+ # Priority 1: User's preferred language
60
+ user_identifier = SessionManager.get('user_identifier')
61
+ if user_identifier:
62
+ user = self.profile_repo.get_user_by_email(user_identifier)
63
+ if user and user.preferred_language:
64
+ logging.debug(f"Language determined by user preference: {user.preferred_language}")
65
+ g.lang = user.preferred_language
66
+ return g.lang
67
+
68
+ # Priority 2: Company's default language
69
+ company_short_name = self._get_company_short_name()
70
+ if company_short_name:
71
+ locale = self.config_service.get_configuration(company_short_name, 'locale')
72
+ if locale:
73
+ company_language = locale.split('_')[0]
74
+ g.lang = company_language
75
+ return g.lang
76
+ except Exception as e:
77
+ logging.info(f"Could not determine language, falling back to default. Reason: {e}")
78
+ pass
79
+
80
+ # Priority 3: System-wide fallback
81
+ logging.info(f"Language determined by system fallback: {self.FALLBACK_LANGUAGE}")
82
+ g.lang = self.FALLBACK_LANGUAGE
83
+ return g.lang
@@ -72,7 +72,7 @@ class LoadDocumentsService:
72
72
  """
73
73
  if not connector_config:
74
74
  raise IAToolkitException(IAToolkitException.ErrorType.MISSING_PARAMETER,
75
- f"Falta configurar conector")
75
+ f"Missing connector config")
76
76
 
77
77
  try:
78
78
  if not filters:
@@ -123,7 +123,7 @@ class LoadDocumentsService:
123
123
 
124
124
  if not company:
125
125
  raise IAToolkitException(IAToolkitException.ErrorType.MISSING_PARAMETER,
126
- f"Falta configurar empresa")
126
+ f"missing company")
127
127
 
128
128
  # check if file exist in repositories
129
129
  if self.doc_repo.get(company_id=company.id,filename=filename):
@@ -182,6 +182,6 @@ class LoadDocumentsService:
182
182
  self.doc_repo.session.rollback()
183
183
 
184
184
  # if something fails, throw exception
185
- logging.exception("Error procesando el archivo %s: %s", filename, str(e))
185
+ logging.exception("Error processing file %s: %s", filename, str(e))
186
186
  raise IAToolkitException(IAToolkitException.ErrorType.LOAD_DOCUMENT_ERROR,
187
- f"Error al procesar el archivo {filename}")
187
+ f"Error while processing file: {filename}")
@@ -4,6 +4,7 @@
4
4
  # IAToolkit is open source software.
5
5
 
6
6
  from iatoolkit.infra.mail_app import MailApp
7
+ from iatoolkit.services.i18n_service import I18nService
7
8
  from injector import inject
8
9
  from pathlib import Path
9
10
  from iatoolkit.common.exceptions import IAToolkitException
@@ -13,18 +14,22 @@ TEMP_DIR = Path("static/temp")
13
14
 
14
15
  class MailService:
15
16
  @inject
16
- def __init__(self, mail_app: MailApp):
17
+ def __init__(self,
18
+ mail_app: MailApp,
19
+ i18n_service: I18nService):
17
20
  self.mail_app = mail_app
21
+ self.i18n_service = i18n_service
22
+
18
23
 
19
24
  def _read_token_bytes(self, token: str) -> bytes:
20
25
  # Defensa simple contra path traversal
21
26
  if not token or "/" in token or "\\" in token or token.startswith("."):
22
27
  raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
23
- "attachment_token inválido")
28
+ "attachment_token invalid")
24
29
  path = TEMP_DIR / token
25
30
  if not path.is_file():
26
31
  raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
27
- f"Adjunto no encontrado: {token}")
32
+ f"attach file not found: {token}")
28
33
  return path.read_bytes()
29
34
 
30
35
  def send_mail(self, **kwargs):
@@ -59,4 +64,4 @@ class MailService:
59
64
  body=body,
60
65
  attachments=norm_attachments)
61
66
 
62
- return 'mail enviado'
67
+ return self.i18n_service.t('services.mail_sent')