iatoolkit 0.3.9__py3-none-any.whl → 0.107.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 (150) hide show
  1. iatoolkit/__init__.py +27 -35
  2. iatoolkit/base_company.py +3 -35
  3. iatoolkit/cli_commands.py +18 -47
  4. iatoolkit/common/__init__.py +0 -0
  5. iatoolkit/common/exceptions.py +48 -0
  6. iatoolkit/common/interfaces/__init__.py +0 -0
  7. iatoolkit/common/interfaces/asset_storage.py +34 -0
  8. iatoolkit/common/interfaces/database_provider.py +39 -0
  9. iatoolkit/common/model_registry.py +159 -0
  10. iatoolkit/common/routes.py +138 -0
  11. iatoolkit/common/session_manager.py +26 -0
  12. iatoolkit/common/util.py +353 -0
  13. iatoolkit/company_registry.py +66 -29
  14. iatoolkit/core.py +514 -0
  15. iatoolkit/infra/__init__.py +5 -0
  16. iatoolkit/infra/brevo_mail_app.py +123 -0
  17. iatoolkit/infra/call_service.py +140 -0
  18. iatoolkit/infra/connectors/__init__.py +5 -0
  19. iatoolkit/infra/connectors/file_connector.py +17 -0
  20. iatoolkit/infra/connectors/file_connector_factory.py +57 -0
  21. iatoolkit/infra/connectors/google_cloud_storage_connector.py +53 -0
  22. iatoolkit/infra/connectors/google_drive_connector.py +68 -0
  23. iatoolkit/infra/connectors/local_file_connector.py +46 -0
  24. iatoolkit/infra/connectors/s3_connector.py +33 -0
  25. iatoolkit/infra/google_chat_app.py +57 -0
  26. iatoolkit/infra/llm_providers/__init__.py +0 -0
  27. iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
  28. iatoolkit/infra/llm_providers/gemini_adapter.py +350 -0
  29. iatoolkit/infra/llm_providers/openai_adapter.py +124 -0
  30. iatoolkit/infra/llm_proxy.py +268 -0
  31. iatoolkit/infra/llm_response.py +45 -0
  32. iatoolkit/infra/redis_session_manager.py +122 -0
  33. iatoolkit/locales/en.yaml +222 -0
  34. iatoolkit/locales/es.yaml +225 -0
  35. iatoolkit/repositories/__init__.py +5 -0
  36. iatoolkit/repositories/database_manager.py +187 -0
  37. iatoolkit/repositories/document_repo.py +33 -0
  38. iatoolkit/repositories/filesystem_asset_repository.py +36 -0
  39. iatoolkit/repositories/llm_query_repo.py +105 -0
  40. iatoolkit/repositories/models.py +279 -0
  41. iatoolkit/repositories/profile_repo.py +171 -0
  42. iatoolkit/repositories/vs_repo.py +150 -0
  43. iatoolkit/services/__init__.py +5 -0
  44. iatoolkit/services/auth_service.py +193 -0
  45. {services → iatoolkit/services}/benchmark_service.py +7 -7
  46. iatoolkit/services/branding_service.py +153 -0
  47. iatoolkit/services/company_context_service.py +214 -0
  48. iatoolkit/services/configuration_service.py +375 -0
  49. iatoolkit/services/dispatcher_service.py +134 -0
  50. {services → iatoolkit/services}/document_service.py +20 -8
  51. iatoolkit/services/embedding_service.py +148 -0
  52. iatoolkit/services/excel_service.py +156 -0
  53. {services → iatoolkit/services}/file_processor_service.py +36 -21
  54. iatoolkit/services/history_manager_service.py +208 -0
  55. iatoolkit/services/i18n_service.py +104 -0
  56. iatoolkit/services/jwt_service.py +80 -0
  57. iatoolkit/services/language_service.py +89 -0
  58. iatoolkit/services/license_service.py +82 -0
  59. iatoolkit/services/llm_client_service.py +438 -0
  60. iatoolkit/services/load_documents_service.py +174 -0
  61. iatoolkit/services/mail_service.py +213 -0
  62. {services → iatoolkit/services}/profile_service.py +200 -101
  63. iatoolkit/services/prompt_service.py +303 -0
  64. iatoolkit/services/query_service.py +467 -0
  65. iatoolkit/services/search_service.py +55 -0
  66. iatoolkit/services/sql_service.py +169 -0
  67. iatoolkit/services/tool_service.py +246 -0
  68. iatoolkit/services/user_feedback_service.py +117 -0
  69. iatoolkit/services/user_session_context_service.py +213 -0
  70. iatoolkit/static/images/fernando.jpeg +0 -0
  71. iatoolkit/static/images/iatoolkit_core.png +0 -0
  72. iatoolkit/static/images/iatoolkit_logo.png +0 -0
  73. iatoolkit/static/js/chat_feedback_button.js +80 -0
  74. iatoolkit/static/js/chat_filepond.js +85 -0
  75. iatoolkit/static/js/chat_help_content.js +124 -0
  76. iatoolkit/static/js/chat_history_button.js +110 -0
  77. iatoolkit/static/js/chat_logout_button.js +36 -0
  78. iatoolkit/static/js/chat_main.js +401 -0
  79. iatoolkit/static/js/chat_model_selector.js +227 -0
  80. iatoolkit/static/js/chat_onboarding_button.js +103 -0
  81. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  82. iatoolkit/static/js/chat_reload_button.js +38 -0
  83. iatoolkit/static/styles/chat_iatoolkit.css +559 -0
  84. iatoolkit/static/styles/chat_modal.css +133 -0
  85. iatoolkit/static/styles/chat_public.css +135 -0
  86. iatoolkit/static/styles/documents.css +598 -0
  87. iatoolkit/static/styles/landing_page.css +398 -0
  88. iatoolkit/static/styles/llm_output.css +148 -0
  89. iatoolkit/static/styles/onboarding.css +176 -0
  90. iatoolkit/system_prompts/__init__.py +0 -0
  91. iatoolkit/system_prompts/query_main.prompt +30 -23
  92. iatoolkit/system_prompts/sql_rules.prompt +47 -12
  93. iatoolkit/templates/_company_header.html +45 -0
  94. iatoolkit/templates/_login_widget.html +42 -0
  95. iatoolkit/templates/base.html +78 -0
  96. iatoolkit/templates/change_password.html +66 -0
  97. iatoolkit/templates/chat.html +337 -0
  98. iatoolkit/templates/chat_modals.html +185 -0
  99. iatoolkit/templates/error.html +51 -0
  100. iatoolkit/templates/forgot_password.html +51 -0
  101. iatoolkit/templates/onboarding_shell.html +106 -0
  102. iatoolkit/templates/signup.html +79 -0
  103. iatoolkit/views/__init__.py +5 -0
  104. iatoolkit/views/base_login_view.py +96 -0
  105. iatoolkit/views/change_password_view.py +116 -0
  106. iatoolkit/views/chat_view.py +76 -0
  107. iatoolkit/views/embedding_api_view.py +65 -0
  108. iatoolkit/views/forgot_password_view.py +75 -0
  109. iatoolkit/views/help_content_api_view.py +54 -0
  110. iatoolkit/views/history_api_view.py +56 -0
  111. iatoolkit/views/home_view.py +63 -0
  112. iatoolkit/views/init_context_api_view.py +74 -0
  113. iatoolkit/views/llmquery_api_view.py +59 -0
  114. iatoolkit/views/load_company_configuration_api_view.py +49 -0
  115. iatoolkit/views/load_document_api_view.py +65 -0
  116. iatoolkit/views/login_view.py +170 -0
  117. iatoolkit/views/logout_api_view.py +57 -0
  118. iatoolkit/views/profile_api_view.py +46 -0
  119. iatoolkit/views/prompt_api_view.py +37 -0
  120. iatoolkit/views/root_redirect_view.py +22 -0
  121. iatoolkit/views/signup_view.py +100 -0
  122. iatoolkit/views/static_page_view.py +27 -0
  123. iatoolkit/views/user_feedback_api_view.py +60 -0
  124. iatoolkit/views/users_api_view.py +33 -0
  125. iatoolkit/views/verify_user_view.py +60 -0
  126. iatoolkit-0.107.4.dist-info/METADATA +268 -0
  127. iatoolkit-0.107.4.dist-info/RECORD +132 -0
  128. iatoolkit-0.107.4.dist-info/licenses/LICENSE +21 -0
  129. iatoolkit-0.107.4.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
  130. {iatoolkit-0.3.9.dist-info → iatoolkit-0.107.4.dist-info}/top_level.txt +0 -1
  131. iatoolkit/iatoolkit.py +0 -413
  132. iatoolkit/system_prompts/arquitectura.prompt +0 -32
  133. iatoolkit-0.3.9.dist-info/METADATA +0 -252
  134. iatoolkit-0.3.9.dist-info/RECORD +0 -32
  135. services/__init__.py +0 -5
  136. services/api_service.py +0 -75
  137. services/dispatcher_service.py +0 -351
  138. services/excel_service.py +0 -98
  139. services/history_service.py +0 -45
  140. services/jwt_service.py +0 -91
  141. services/load_documents_service.py +0 -212
  142. services/mail_service.py +0 -62
  143. services/prompt_manager_service.py +0 -172
  144. services/query_service.py +0 -334
  145. services/search_service.py +0 -32
  146. services/sql_service.py +0 -42
  147. services/tasks_service.py +0 -188
  148. services/user_feedback_service.py +0 -67
  149. services/user_session_context_service.py +0 -85
  150. {iatoolkit-0.3.9.dist-info → iatoolkit-0.107.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,438 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from iatoolkit.infra.llm_proxy import LLMProxy
7
+ from iatoolkit.repositories.models import Company, LLMQuery
8
+ from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
9
+ from sqlalchemy.exc import SQLAlchemyError, OperationalError
10
+ from iatoolkit.common.util import Utility
11
+ from iatoolkit.common.model_registry import ModelRegistry
12
+ from injector import inject
13
+ import time
14
+ import markdown2
15
+ import os
16
+ import logging
17
+ import json
18
+ from iatoolkit.common.exceptions import IAToolkitException
19
+ import threading
20
+ import re
21
+ import tiktoken
22
+ from typing import Dict, Optional, List
23
+ from iatoolkit.services.dispatcher_service import Dispatcher
24
+
25
+ CONTEXT_ERROR_MESSAGE = 'Tu consulta supera el límite de contexto, utiliza el boton de recarga de contexto.'
26
+
27
+ class llmClient:
28
+ _llm_clients_cache = {} # class attribute, for the clients cache
29
+ _clients_cache_lock = threading.Lock() # secure lock cache access
30
+
31
+ @inject
32
+ def __init__(self,
33
+ llmquery_repo: LLMQueryRepo,
34
+ llm_proxy: LLMProxy,
35
+ model_registry: ModelRegistry,
36
+ util: Utility
37
+ ):
38
+ self.llmquery_repo = llmquery_repo
39
+ self.llm_proxy = llm_proxy
40
+ self.model_registry = model_registry
41
+ self.util = util
42
+ self._dispatcher = None # Cache for the lazy-loaded dispatcher
43
+
44
+ # library for counting tokens
45
+ self.encoding = tiktoken.encoding_for_model("gpt-4o")
46
+
47
+ # max number of sql retries
48
+ self.MAX_SQL_RETRIES = 1
49
+
50
+ @property
51
+ def dispatcher(self) -> 'Dispatcher':
52
+ """Lazy-loads and returns the Dispatcher instance."""
53
+ if self._dispatcher is None:
54
+ # Import what you need, right when you need it.
55
+ from iatoolkit import current_iatoolkit
56
+ from iatoolkit.services.dispatcher_service import Dispatcher
57
+ # Use the global context proxy to get the injector, then get the service
58
+ self._dispatcher = current_iatoolkit().get_injector().get(Dispatcher)
59
+ return self._dispatcher
60
+
61
+
62
+ def invoke(self,
63
+ company: Company,
64
+ user_identifier: str,
65
+ previous_response_id: str,
66
+ question: str,
67
+ context: str,
68
+ tools: list[dict],
69
+ text: dict,
70
+ model: str,
71
+ context_history: Optional[List[Dict]] = None,
72
+ ) -> dict:
73
+
74
+ f_calls = [] # keep track of the function calls executed by the LLM
75
+ f_call_time = 0
76
+ response = None
77
+ sql_retry_count = 0
78
+ force_tool_name = None
79
+
80
+ # Resolve per-model defaults and apply overrides (without mutating inputs).
81
+ request_params = self.model_registry.resolve_request_params(model=model, text=text)
82
+ text_payload = request_params["text"]
83
+ reasoning = request_params["reasoning"]
84
+
85
+ try:
86
+ start_time = time.time()
87
+ logging.info(f"calling llm model '{model}' with {self.count_tokens(context, context_history)} tokens...")
88
+
89
+ # this is the first call to the LLM on the iteration
90
+ try:
91
+ input_messages = [{
92
+ "role": "user",
93
+ "content": context
94
+ }]
95
+
96
+ response = self.llm_proxy.create_response(
97
+ company_short_name=company.short_name,
98
+ model=model,
99
+ input=input_messages,
100
+ previous_response_id=previous_response_id,
101
+ context_history=context_history,
102
+ tools=tools,
103
+ text=text_payload,
104
+ reasoning=reasoning,
105
+ )
106
+ stats = self.get_stats(response)
107
+
108
+ except Exception as e:
109
+ # if the llm api fails: context, api-key, etc
110
+ # log the error and envolve in our own exception
111
+ error_message = f"Error calling LLM API: {str(e)}"
112
+ logging.error(error_message)
113
+
114
+ # in case of context error
115
+ if "context_length_exceeded" in str(e):
116
+ error_message = CONTEXT_ERROR_MESSAGE
117
+
118
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
119
+
120
+ while True:
121
+ # check if there are function calls to execute
122
+ function_calls = False
123
+ stats_fcall = {}
124
+ for tool_call in response.output:
125
+ if tool_call.type != "function_call":
126
+ continue
127
+
128
+ # execute the function call through the dispatcher
129
+ fcall_time = time.time()
130
+ function_name = tool_call.name
131
+
132
+ try:
133
+ args = json.loads(tool_call.arguments)
134
+ except Exception as e:
135
+ logging.error(f"[Dispatcher] json.loads failed: {e}")
136
+ raise
137
+ logging.debug(f"[Dispatcher] Parsed args = {args}")
138
+
139
+ try:
140
+ result = self.dispatcher.dispatch(
141
+ company_short_name=company.short_name,
142
+ function_name=function_name,
143
+ **args
144
+ )
145
+ force_tool_name = None
146
+ except IAToolkitException as e:
147
+ if (e.error_type == IAToolkitException.ErrorType.DATABASE_ERROR and
148
+ sql_retry_count < self.MAX_SQL_RETRIES):
149
+ sql_retry_count += 1
150
+ sql_query_with_error = args.get('query', 'No se pudo extraer la consulta.')
151
+ original_db_error = str(e.__cause__) if e.__cause__ else str(e)
152
+
153
+ logging.warning(
154
+ f"Error de SQL capturado, intentando corregir con el LLM (Intento {sql_retry_count}/{self.MAX_SQL_RETRIES}).")
155
+ result = self._create_sql_retry_prompt(function_name, sql_query_with_error, original_db_error)
156
+
157
+ # force the next call to be this function
158
+ force_tool_name = function_name
159
+ else:
160
+ error_message = f"Error en dispatch para '{function_name}' tras {sql_retry_count} reintentos: {str(e)}"
161
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
162
+ except Exception as e:
163
+ error_message = f"Dispatch error en {function_name} con args {args} -******- {str(e)}"
164
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
165
+
166
+ # add the return value into the list of messages
167
+ input_messages.append({
168
+ "type": "function_call_output",
169
+ "call_id": tool_call.call_id,
170
+ "status": "completed",
171
+ "output": str(result)
172
+ })
173
+ function_calls = True
174
+
175
+ # log the function call parameters and execution time in secs
176
+ elapsed = time.time() - fcall_time
177
+ f_call_identity = {function_name:args, 'time': f'{elapsed:.1f}' }
178
+ f_calls.append(f_call_identity)
179
+ f_call_time += elapsed
180
+
181
+ logging.info(f"[{company.short_name}] end execution of tool: {function_name} in {elapsed:.1f} secs.")
182
+
183
+ if not function_calls:
184
+ break # no more function calls, the answer to send back to llm
185
+
186
+ # send results back to the LLM
187
+ tool_choice_value = "auto"
188
+ if force_tool_name:
189
+ tool_choice_value = "required"
190
+
191
+ response = self.llm_proxy.create_response(
192
+ company_short_name=company.short_name,
193
+ model=model,
194
+ input=input_messages,
195
+ previous_response_id=response.id,
196
+ context_history=context_history,
197
+ reasoning=reasoning,
198
+ tool_choice=tool_choice_value,
199
+ tools=tools,
200
+ text=text_payload,
201
+ )
202
+ stats_fcall = self.add_stats(stats_fcall, self.get_stats(response))
203
+
204
+ # save the statistices
205
+ stats['response_time']=int(time.time() - start_time)
206
+ stats['sql_retry_count'] = sql_retry_count
207
+ stats['model'] = model
208
+
209
+ # decode the LLM response
210
+ decoded_response = self.decode_response(response)
211
+
212
+ # Extract reasoning from the final response object
213
+ final_reasoning = getattr(response, 'reasoning_content', '')
214
+
215
+ # save the query and response
216
+ query = LLMQuery(user_identifier=user_identifier,
217
+ company_id=company.id,
218
+ query=question,
219
+ output=decoded_response.get('answer', ''),
220
+ valid_response=decoded_response.get('status', False),
221
+ response=self.serialize_response(response, decoded_response),
222
+ function_calls=f_calls,
223
+ stats=self.add_stats(stats, stats_fcall),
224
+ answer_time=stats['response_time']
225
+ )
226
+ self.llmquery_repo.add_query(query)
227
+ logging.info(f"finish llm call in {int(time.time() - start_time)} secs..")
228
+ if function_calls:
229
+ logging.info(f"time within the function calls {f_call_time:.1f} secs.")
230
+
231
+ return {
232
+ 'valid_response': decoded_response.get('status', False),
233
+ 'answer': self.format_html(decoded_response.get('answer', '')),
234
+ 'stats': stats,
235
+ 'answer_format': decoded_response.get('answer_format', ''),
236
+ 'error_message': decoded_response.get('error_message', ''),
237
+ 'aditional_data': decoded_response.get('aditional_data', {}),
238
+ 'response_id': response.id,
239
+ 'query_id': query.id,
240
+ 'model': model,
241
+ 'reasoning_content': final_reasoning,
242
+ }
243
+ except SQLAlchemyError as db_error:
244
+ # rollback
245
+ self.llmquery_repo.session.rollback()
246
+ logging.error(f"Error de base de datos: {str(db_error)}")
247
+ raise db_error
248
+ except OperationalError as e:
249
+ logging.error(f"Operational error: {str(e)}")
250
+ raise e
251
+ except Exception as e:
252
+ error_message= str(e)
253
+
254
+ # log the error in the llm_query table
255
+ query = LLMQuery(user_identifier=user_identifier,
256
+ company_id=company.id,
257
+ query=question,
258
+ output=error_message,
259
+ response={},
260
+ valid_response=False,
261
+ function_calls=f_calls,
262
+ )
263
+ self.llmquery_repo.add_query(query)
264
+
265
+ # in case of context error
266
+ if "context_length_exceeded" in str(e):
267
+ error_message = CONTEXT_ERROR_MESSAGE
268
+ elif "string_above_max_length" in str(e):
269
+ error_message = 'La respuesta es muy extensa, trata de filtrar/restringuir tu consulta'
270
+
271
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
272
+
273
+ def set_company_context(self,
274
+ company: Company,
275
+ company_base_context: str,
276
+ model) -> str:
277
+
278
+ logging.info(f"initializing model '{model}' with company context: {self.count_tokens(company_base_context)} tokens...")
279
+
280
+ try:
281
+ response = self.llm_proxy.create_response(
282
+ company_short_name=company.short_name,
283
+ model=model,
284
+ input=[{
285
+ "role": "system",
286
+ "content": company_base_context
287
+ }],
288
+
289
+ )
290
+
291
+ except Exception as e:
292
+ error_message = f"Error calling LLM API: {str(e)}"
293
+ logging.error(error_message)
294
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
295
+
296
+ return response.id
297
+
298
+ def decode_response(self, response) -> dict:
299
+ message = response.output_text
300
+ decoded_response = {
301
+ "status": False,
302
+ "output_text": message,
303
+ "answer": "",
304
+ "aditional_data": {},
305
+ "answer_format": "",
306
+ "error_message": ""
307
+ }
308
+
309
+ if response.status != 'completed':
310
+ decoded_response[
311
+ 'error_message'] = f'LLM ERROR {response.status}: no se completo tu pregunta, intenta de nuevo ...'
312
+ return decoded_response
313
+
314
+ if isinstance(message, dict):
315
+ if 'answer' not in message or 'aditional_data' not in message:
316
+ decoded_response['error_message'] = 'El llm respondio un diccionario invalido: missing "answer" key'
317
+ return decoded_response
318
+
319
+ decoded_response['status'] = True
320
+ decoded_response['answer'] = message.get('answer', '')
321
+ decoded_response['aditional_data'] = message.get('aditional_data', {})
322
+ decoded_response['answer_format'] = "dict"
323
+ return decoded_response
324
+
325
+ clean_message = re.sub(r'^\s*//.*$', '', message, flags=re.MULTILINE)
326
+
327
+ if not ('```json' in clean_message or clean_message.strip().startswith('{')):
328
+ decoded_response['status'] = True
329
+ decoded_response['answer'] = clean_message
330
+ decoded_response['answer_format'] = "plaintext"
331
+ return decoded_response
332
+
333
+ try:
334
+ # prepare the message for json load
335
+ json_string = clean_message.strip()
336
+ if json_string.startswith('```json'):
337
+ json_string = json_string[7:]
338
+ if json_string.endswith('```'):
339
+ json_string = json_string[:-3]
340
+
341
+ response_dict = json.loads(json_string.strip())
342
+ except Exception as e:
343
+ # --- ESTRATEGIA DE RESPALDO (FALLBACK) CON RESCATE DE DATOS ---
344
+ decoded_response['error_message'] = f'Error decodificando JSON: {str(e)}'
345
+
346
+ # Intenta rescatar el contenido de "answer" con una expresión regular más robusta.
347
+ # Este patrón busca "answer": "..." y captura todo hasta que encuentra "," y "aditional_data".
348
+ # re.DOTALL es crucial para que `.` coincida con los saltos de línea en el HTML.
349
+ match = re.search(r'"answer"\s*:\s*"(.*?)"\s*,\s*"aditional_data"', clean_message, re.DOTALL)
350
+
351
+ if match:
352
+ # ¡Éxito! Se encontró y extrajo el "answer".
353
+ # Se limpia el contenido de escapes JSON para obtener el HTML puro.
354
+ rescued_answer = match.group(1).replace('\\n', '\n').replace('\\"', '"')
355
+
356
+ decoded_response['status'] = True
357
+ decoded_response['answer'] = rescued_answer
358
+ decoded_response['answer_format'] = "plaintext_fallback_rescued"
359
+ else:
360
+ # Si la regex no encuentra nada, usar el texto completo como último recurso.
361
+ decoded_response['status'] = True
362
+ decoded_response['answer'] = clean_message
363
+ decoded_response['answer_format'] = "plaintext_fallback_full"
364
+ else:
365
+ # --- SOLO SE EJECUTA SI EL TRY FUE EXITOSO ---
366
+ if 'answer' not in response_dict or 'aditional_data' not in response_dict:
367
+ decoded_response['error_message'] = f'faltan las claves "answer" o "aditional_data" en el JSON'
368
+
369
+ # fallback
370
+ decoded_response['status'] = True
371
+ decoded_response['answer'] = str(response_dict)
372
+ decoded_response['answer_format'] = "json_fallback"
373
+ else:
374
+ # El diccionario JSON es perfecto.
375
+ decoded_response['status'] = True
376
+ decoded_response['answer'] = response_dict.get('answer', '')
377
+ decoded_response['aditional_data'] = response_dict.get('aditional_data', {})
378
+ decoded_response['answer_format'] = "json_string"
379
+
380
+ return decoded_response
381
+
382
+ def serialize_response(self, response, decoded_response):
383
+ response_dict = {
384
+ "format": decoded_response.get('answer_format', ''),
385
+ "error_message": decoded_response.get('error_message', ''),
386
+ "output": decoded_response.get('output_text', ''),
387
+ "id": response.id,
388
+ "model": response.model,
389
+ "status": response.status,
390
+ }
391
+ return response_dict
392
+
393
+ def get_stats(self, response):
394
+ stats_dict = {
395
+ "input_tokens": response.usage.input_tokens,
396
+ "output_tokens": response.usage.output_tokens,
397
+ "total_tokens": response.usage.total_tokens
398
+ }
399
+ return stats_dict
400
+
401
+ def add_stats(self, stats1: dict, stats2: dict) -> dict:
402
+ stats_dict = {
403
+ "model": stats1.get('model', ''),
404
+ "input_tokens": stats1.get('input_tokens', 0) + stats2.get('input_tokens', 0),
405
+ "output_tokens": stats1.get('output_tokens', 0) + stats2.get('output_tokens', 0),
406
+ "total_tokens": stats1.get('total_tokens', 0) + stats2.get('total_tokens', 0),
407
+ }
408
+ return stats_dict
409
+
410
+
411
+ def _create_sql_retry_prompt(self, function_name: str, sql_query: str, db_error: str) -> str:
412
+ return f"""
413
+ ## ERROR DE EJECUCIÓN DE HERRAMIENTA
414
+
415
+ **Estado:** Fallido
416
+ **Herramienta:** `{function_name}`
417
+
418
+ La ejecución de la consulta SQL falló.
419
+
420
+ **Error específico de la base de datos:**
421
+ {db_error}
422
+ **Consulta SQL que causó el error:**
423
+ sql {sql_query}
424
+
425
+ **INSTRUCCIÓN OBLIGATORIA:**
426
+ 1. Analiza el error y corrige la sintaxis de la consulta SQL anterior.
427
+ 2. Llama a la herramienta `{function_name}` **OTRA VEZ**, inmediatamente, con la consulta corregida.
428
+ 3. **NO** respondas al usuario con este mensaje de error. Tu ÚNICA acción debe ser volver a llamar a la herramienta con la solución.
429
+ """
430
+
431
+ def format_html(self, answer: str):
432
+ html_answer = markdown2.markdown(answer).replace("\n", "")
433
+ return html_answer
434
+
435
+ def count_tokens(self, text, history = []):
436
+ # Codifica el texto y cuenta la cantidad de tokens
437
+ tokens = self.encoding.encode(text + json.dumps(history))
438
+ return len(tokens)
@@ -0,0 +1,174 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+
4
+ from iatoolkit.repositories.vs_repo import VSRepo
5
+ from iatoolkit.repositories.document_repo import DocumentRepo
6
+ from iatoolkit.repositories.models import Document, VSDoc, Company
7
+ from iatoolkit.services.document_service import DocumentService
8
+ from iatoolkit.services.configuration_service import ConfigurationService
9
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
10
+ from iatoolkit.infra.connectors.file_connector_factory import FileConnectorFactory
11
+ from iatoolkit.services.file_processor_service import FileProcessorConfig, FileProcessor
12
+ from iatoolkit.common.exceptions import IAToolkitException
13
+ import logging
14
+ import base64
15
+ from injector import inject, singleton
16
+ import os
17
+
18
+
19
+ @singleton
20
+ class LoadDocumentsService:
21
+ """
22
+ Orchestrates the process of loading, processing, and storing documents
23
+ from various sources defined in the company's configuration.
24
+ """
25
+ @inject
26
+ def __init__(self,
27
+ config_service: ConfigurationService,
28
+ file_connector_factory: FileConnectorFactory,
29
+ doc_service: DocumentService,
30
+ doc_repo: DocumentRepo,
31
+ vector_store: VSRepo,
32
+ ):
33
+ self.config_service = config_service
34
+ self.doc_service = doc_service
35
+ self.doc_repo = doc_repo
36
+ self.vector_store = vector_store
37
+ self.file_connector_factory = file_connector_factory
38
+
39
+ logging.getLogger().setLevel(logging.ERROR)
40
+
41
+ self.splitter = RecursiveCharacterTextSplitter(
42
+ chunk_size=1000,
43
+ chunk_overlap=100,
44
+ separators=["\n\n", "\n", "."]
45
+ )
46
+
47
+ def load_sources(self,
48
+ company: Company,
49
+ sources_to_load: list[str] = None,
50
+ filters: dict = None) -> int:
51
+ """
52
+ Loads documents from one or more configured sources for a company.
53
+
54
+ Args:
55
+ company (Company): The company to load files for.
56
+ sources_to_load (list[str], optional): A list of specific source names to load.
57
+ If None, all configured sources will be loaded.
58
+ filters (dict, optional): Filters to apply when listing files (e.g., file extension).
59
+
60
+ Returns:
61
+ int: The total number of processed files.
62
+ """
63
+ knowledge_base_config = self.config_service.get_configuration(company.short_name, 'knowledge_base')
64
+ if not knowledge_base_config:
65
+ raise IAToolkitException(IAToolkitException.ErrorType.CONFIG_ERROR,
66
+ f"Missing 'knowledge_base' configuration for company '{company.short_name}'.")
67
+
68
+ if not sources_to_load:
69
+ raise IAToolkitException(IAToolkitException.ErrorType.PARAM_NOT_FILLED,
70
+ f"Missing sources to load for company '{company.short_name}'.")
71
+
72
+ base_connector_config = self._get_base_connector_config(knowledge_base_config)
73
+ all_sources = knowledge_base_config.get('document_sources', {})
74
+
75
+ total_processed_files = 0
76
+ for source_name in sources_to_load:
77
+ source_config = all_sources.get(source_name)
78
+ if not source_config:
79
+ logging.warning(f"Source '{source_name}' not found in configuration for company '{company.short_name}'. Skipping.")
80
+ continue
81
+
82
+ try:
83
+ logging.info(f"Processing source '{source_name}' for company '{company.short_name}'...")
84
+
85
+ # Combine the base connector configuration with the specific path from the source.
86
+ full_connector_config = base_connector_config.copy()
87
+ full_connector_config['path'] = source_config.get('path')
88
+
89
+ # Prepare the context for the callback function.
90
+ context = {
91
+ 'company': company,
92
+ 'metadata': source_config.get('metadata', {})
93
+ }
94
+
95
+ processor_config = FileProcessorConfig(
96
+ callback=self._file_processing_callback,
97
+ context=context,
98
+ filters=filters or {"filename_contains": ".pdf"},
99
+ continue_on_error=True,
100
+ echo=True
101
+ )
102
+
103
+ connector = self.file_connector_factory.create(full_connector_config)
104
+ processor = FileProcessor(connector, processor_config)
105
+ processor.process_files()
106
+
107
+ total_processed_files += processor.processed_files
108
+ logging.info(f"Finished processing source '{source_name}'. Processed {processor.processed_files} files.")
109
+
110
+ except Exception as e:
111
+ logging.exception(f"Failed to process source '{source_name}' for company '{company.short_name}': {e}")
112
+
113
+ return total_processed_files
114
+
115
+ def _get_base_connector_config(self, knowledge_base_config: dict) -> dict:
116
+ """Determines and returns the appropriate base connector configuration (dev vs prod)."""
117
+ connectors = knowledge_base_config.get('connectors', {})
118
+ env = os.getenv('FLASK_ENV', 'dev')
119
+
120
+ if env == 'dev':
121
+ return connectors.get('development', {'type': 'local'})
122
+ else:
123
+ prod_config = connectors.get('production')
124
+ if not prod_config:
125
+ raise IAToolkitException(IAToolkitException.ErrorType.CONFIG_ERROR,
126
+ "Production connector configuration is missing.")
127
+ # The S3 connector itself is responsible for reading AWS environment variables.
128
+ # No need to pass credentials explicitly here.
129
+ return prod_config
130
+
131
+ def _file_processing_callback(self, company: Company, filename: str, content: bytes, context: dict = None):
132
+ """
133
+ Callback method to process a single file. It extracts text, merges metadata,
134
+ and saves the document to both relational and vector stores.
135
+ """
136
+ if not company:
137
+ raise IAToolkitException(IAToolkitException.ErrorType.MISSING_PARAMETER, "Missing company object in callback.")
138
+
139
+ if self.doc_repo.get(company_id=company.id, filename=filename):
140
+ logging.debug(f"File '{filename}' already exists for company '{company.id}'. Skipping.")
141
+ return
142
+
143
+ try:
144
+ document_content = self.doc_service.file_to_txt(filename, content)
145
+
146
+ # Get predefined metadata from the context passed by the processor.
147
+ predefined_metadata = context.get('metadata', {}) if context else {}
148
+
149
+ # Save the document to the relational database.
150
+ session = self.doc_repo.session
151
+ new_document = Document(
152
+ company_id=company.id,
153
+ filename=filename,
154
+ content=document_content,
155
+ content_b64=base64.b64encode(content).decode('utf-8'),
156
+ meta=predefined_metadata
157
+ )
158
+ session.add(new_document)
159
+ session.flush() # Flush to get the new_document.id without committing.
160
+
161
+ # Split into chunks and prepare for vector store.
162
+ chunks = self.splitter.split_text(document_content)
163
+ vs_docs = [VSDoc(company_id=company.id, document_id=new_document.id, text=text) for text in chunks]
164
+
165
+ # Add document chunks to the vector store.
166
+ self.vector_store.add_document(company.short_name, vs_docs)
167
+
168
+ session.commit()
169
+ return new_document
170
+ except Exception as e:
171
+ self.doc_repo.session.rollback()
172
+ logging.exception(f"Error processing file '{filename}': {e}")
173
+ raise IAToolkitException(IAToolkitException.ErrorType.LOAD_DOCUMENT_ERROR,
174
+ f"Error while processing file: {filename}")