iatoolkit 0.4.2__py3-none-any.whl → 0.66.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 (123) hide show
  1. iatoolkit/__init__.py +13 -35
  2. iatoolkit/base_company.py +74 -8
  3. iatoolkit/cli_commands.py +15 -23
  4. iatoolkit/common/__init__.py +0 -0
  5. iatoolkit/common/exceptions.py +46 -0
  6. iatoolkit/common/routes.py +141 -0
  7. iatoolkit/common/session_manager.py +24 -0
  8. iatoolkit/common/util.py +348 -0
  9. iatoolkit/company_registry.py +7 -8
  10. iatoolkit/iatoolkit.py +169 -96
  11. iatoolkit/infra/__init__.py +5 -0
  12. iatoolkit/infra/call_service.py +140 -0
  13. iatoolkit/infra/connectors/__init__.py +5 -0
  14. iatoolkit/infra/connectors/file_connector.py +17 -0
  15. iatoolkit/infra/connectors/file_connector_factory.py +57 -0
  16. iatoolkit/infra/connectors/google_cloud_storage_connector.py +53 -0
  17. iatoolkit/infra/connectors/google_drive_connector.py +68 -0
  18. iatoolkit/infra/connectors/local_file_connector.py +46 -0
  19. iatoolkit/infra/connectors/s3_connector.py +33 -0
  20. iatoolkit/infra/gemini_adapter.py +356 -0
  21. iatoolkit/infra/google_chat_app.py +57 -0
  22. iatoolkit/infra/llm_client.py +429 -0
  23. iatoolkit/infra/llm_proxy.py +139 -0
  24. iatoolkit/infra/llm_response.py +40 -0
  25. iatoolkit/infra/mail_app.py +145 -0
  26. iatoolkit/infra/openai_adapter.py +90 -0
  27. iatoolkit/infra/redis_session_manager.py +122 -0
  28. iatoolkit/locales/en.yaml +144 -0
  29. iatoolkit/locales/es.yaml +140 -0
  30. iatoolkit/repositories/__init__.py +5 -0
  31. iatoolkit/repositories/database_manager.py +110 -0
  32. iatoolkit/repositories/document_repo.py +33 -0
  33. iatoolkit/repositories/llm_query_repo.py +91 -0
  34. iatoolkit/repositories/models.py +336 -0
  35. iatoolkit/repositories/profile_repo.py +123 -0
  36. iatoolkit/repositories/tasks_repo.py +52 -0
  37. iatoolkit/repositories/vs_repo.py +139 -0
  38. iatoolkit/services/__init__.py +5 -0
  39. iatoolkit/services/auth_service.py +193 -0
  40. {services → iatoolkit/services}/benchmark_service.py +6 -6
  41. iatoolkit/services/branding_service.py +149 -0
  42. {services → iatoolkit/services}/dispatcher_service.py +39 -99
  43. {services → iatoolkit/services}/document_service.py +5 -5
  44. {services → iatoolkit/services}/excel_service.py +27 -21
  45. {services → iatoolkit/services}/file_processor_service.py +5 -5
  46. iatoolkit/services/help_content_service.py +30 -0
  47. {services → iatoolkit/services}/history_service.py +8 -16
  48. iatoolkit/services/i18n_service.py +104 -0
  49. {services → iatoolkit/services}/jwt_service.py +18 -27
  50. iatoolkit/services/language_service.py +77 -0
  51. {services → iatoolkit/services}/load_documents_service.py +19 -14
  52. {services → iatoolkit/services}/mail_service.py +5 -5
  53. iatoolkit/services/onboarding_service.py +43 -0
  54. {services → iatoolkit/services}/profile_service.py +155 -89
  55. {services → iatoolkit/services}/prompt_manager_service.py +26 -11
  56. {services → iatoolkit/services}/query_service.py +142 -104
  57. {services → iatoolkit/services}/search_service.py +21 -5
  58. {services → iatoolkit/services}/sql_service.py +24 -6
  59. {services → iatoolkit/services}/tasks_service.py +10 -10
  60. iatoolkit/services/user_feedback_service.py +103 -0
  61. iatoolkit/services/user_session_context_service.py +143 -0
  62. iatoolkit/static/images/fernando.jpeg +0 -0
  63. iatoolkit/static/js/chat_feedback_button.js +80 -0
  64. iatoolkit/static/js/chat_filepond.js +85 -0
  65. iatoolkit/static/js/chat_help_content.js +124 -0
  66. iatoolkit/static/js/chat_history_button.js +112 -0
  67. iatoolkit/static/js/chat_logout_button.js +36 -0
  68. iatoolkit/static/js/chat_main.js +364 -0
  69. iatoolkit/static/js/chat_onboarding_button.js +97 -0
  70. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  71. iatoolkit/static/js/chat_reload_button.js +35 -0
  72. iatoolkit/static/styles/chat_iatoolkit.css +592 -0
  73. iatoolkit/static/styles/chat_modal.css +169 -0
  74. iatoolkit/static/styles/chat_public.css +107 -0
  75. iatoolkit/static/styles/landing_page.css +182 -0
  76. iatoolkit/static/styles/llm_output.css +115 -0
  77. iatoolkit/static/styles/onboarding.css +169 -0
  78. iatoolkit/system_prompts/query_main.prompt +5 -15
  79. iatoolkit/templates/_company_header.html +20 -0
  80. iatoolkit/templates/_login_widget.html +42 -0
  81. iatoolkit/templates/about.html +13 -0
  82. iatoolkit/templates/base.html +65 -0
  83. iatoolkit/templates/change_password.html +66 -0
  84. iatoolkit/templates/chat.html +287 -0
  85. iatoolkit/templates/chat_modals.html +181 -0
  86. iatoolkit/templates/error.html +51 -0
  87. iatoolkit/templates/forgot_password.html +50 -0
  88. iatoolkit/templates/index.html +145 -0
  89. iatoolkit/templates/login_simulation.html +34 -0
  90. iatoolkit/templates/onboarding_shell.html +104 -0
  91. iatoolkit/templates/signup.html +76 -0
  92. iatoolkit/views/__init__.py +5 -0
  93. iatoolkit/views/base_login_view.py +92 -0
  94. iatoolkit/views/change_password_view.py +117 -0
  95. iatoolkit/views/external_login_view.py +73 -0
  96. iatoolkit/views/file_store_api_view.py +65 -0
  97. iatoolkit/views/forgot_password_view.py +72 -0
  98. iatoolkit/views/help_content_api_view.py +54 -0
  99. iatoolkit/views/history_api_view.py +56 -0
  100. iatoolkit/views/home_view.py +61 -0
  101. iatoolkit/views/index_view.py +14 -0
  102. iatoolkit/views/init_context_api_view.py +73 -0
  103. iatoolkit/views/llmquery_api_view.py +57 -0
  104. iatoolkit/views/login_simulation_view.py +81 -0
  105. iatoolkit/views/login_view.py +153 -0
  106. iatoolkit/views/logout_api_view.py +49 -0
  107. iatoolkit/views/profile_api_view.py +46 -0
  108. iatoolkit/views/prompt_api_view.py +37 -0
  109. iatoolkit/views/signup_view.py +94 -0
  110. iatoolkit/views/tasks_api_view.py +72 -0
  111. iatoolkit/views/tasks_review_api_view.py +55 -0
  112. iatoolkit/views/user_feedback_api_view.py +60 -0
  113. iatoolkit/views/verify_user_view.py +62 -0
  114. {iatoolkit-0.4.2.dist-info → iatoolkit-0.66.2.dist-info}/METADATA +2 -2
  115. iatoolkit-0.66.2.dist-info/RECORD +119 -0
  116. {iatoolkit-0.4.2.dist-info → iatoolkit-0.66.2.dist-info}/top_level.txt +0 -1
  117. iatoolkit/system_prompts/arquitectura.prompt +0 -32
  118. iatoolkit-0.4.2.dist-info/RECORD +0 -32
  119. services/__init__.py +0 -5
  120. services/api_service.py +0 -75
  121. services/user_feedback_service.py +0 -67
  122. services/user_session_context_service.py +0 -85
  123. {iatoolkit-0.4.2.dist-info → iatoolkit-0.66.2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,429 @@
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 injector import inject
12
+ import time
13
+ import markdown2
14
+ import os
15
+ import logging
16
+ import json
17
+ from iatoolkit.common.exceptions import IAToolkitException
18
+ import threading
19
+ import re
20
+ import tiktoken
21
+ from typing import Dict, Optional, List
22
+ from iatoolkit.services.dispatcher_service import Dispatcher
23
+
24
+ CONTEXT_ERROR_MESSAGE = 'Tu consulta supera el límite de contexto, utiliza el boton de recarga de contexto.'
25
+
26
+ class llmClient:
27
+ _llm_clients_cache = {} # class attribute, for the clients cache
28
+ _clients_cache_lock = threading.Lock() # secure lock cache access
29
+
30
+ @inject
31
+ def __init__(self,
32
+ llmquery_repo: LLMQueryRepo,
33
+ llm_proxy_factory: LLMProxy,
34
+ util: Utility
35
+ ):
36
+ self.llmquery_repo = llmquery_repo
37
+ self.llm_proxy_factory = llm_proxy_factory
38
+ self.util = util
39
+ self._dispatcher = None # Cache for the lazy-loaded dispatcher
40
+
41
+ # get the model from the environment variable
42
+ self.model = os.getenv("LLM_MODEL", "")
43
+ if not self.model:
44
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
45
+ "La variable de entorno 'LLM_MODEL' no está configurada.")
46
+
47
+ # library for counting tokens
48
+ self.encoding = tiktoken.encoding_for_model("gpt-4o")
49
+
50
+ # max number of sql retries
51
+ self.MAX_SQL_RETRIES = 1
52
+
53
+ @property
54
+ def dispatcher(self) -> 'Dispatcher':
55
+ """Lazy-loads and returns the Dispatcher instance."""
56
+ if self._dispatcher is None:
57
+ # Import what you need, right when you need it.
58
+ from iatoolkit import current_iatoolkit
59
+ from iatoolkit.services.dispatcher_service import Dispatcher
60
+ # Use the global context proxy to get the injector, then get the service
61
+ self._dispatcher = current_iatoolkit().get_injector().get(Dispatcher)
62
+ return self._dispatcher
63
+
64
+
65
+ def invoke(self,
66
+ company: Company,
67
+ user_identifier: str,
68
+ previous_response_id: str,
69
+ question: str,
70
+ context: str,
71
+ tools: list[dict],
72
+ text: dict,
73
+ context_history: Optional[List[Dict]] = None,
74
+ ) -> dict:
75
+
76
+ f_calls = [] # keep track of the function calls executed by the LLM
77
+ f_call_time = 0
78
+ response = None
79
+ sql_retry_count = 0
80
+ force_tool_name = None
81
+ reasoning = {}
82
+
83
+ if 'gpt-5' in self.model:
84
+ text['verbosity'] = "low"
85
+ reasoning = {"effort": 'minimal'}
86
+
87
+ try:
88
+ start_time = time.time()
89
+ logging.info(f"calling llm model '{self.model}' with {self.count_tokens(context)} tokens...")
90
+
91
+ # get the proxy for the company
92
+ llm_proxy = self.llm_proxy_factory.create_for_company(company)
93
+
94
+ # here is the first call to the LLM
95
+ try:
96
+ input_messages = [{
97
+ "role": "user",
98
+ "content": context
99
+ }]
100
+
101
+ response = llm_proxy.create_response(
102
+ model=self.model,
103
+ previous_response_id=previous_response_id,
104
+ context_history=context_history,
105
+ input=input_messages,
106
+ tools=tools,
107
+ text=text,
108
+ reasoning=reasoning,
109
+ )
110
+ stats = self.get_stats(response)
111
+
112
+ except Exception as e:
113
+ # if the llm api fails: context, api-key, etc
114
+ # log the error and envolve in our own exception
115
+ error_message = f"Error calling LLM API: {str(e)}"
116
+ logging.error(error_message)
117
+
118
+ # in case of context error
119
+ if "context_length_exceeded" in str(e):
120
+ error_message = CONTEXT_ERROR_MESSAGE
121
+
122
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
123
+
124
+ while True:
125
+ # check if there are function calls to execute
126
+ function_calls = False
127
+ stats_fcall = {}
128
+ for tool_call in response.output:
129
+ if tool_call.type != "function_call":
130
+ continue
131
+
132
+ # execute the function call through the dispatcher
133
+ fcall_time = time.time()
134
+ function_name = tool_call.name
135
+ args = json.loads(tool_call.arguments)
136
+ logging.info(f"start execution fcall: {function_name}")
137
+ try:
138
+ result = self.dispatcher.dispatch(
139
+ company_name=company.short_name,
140
+ action=function_name,
141
+ **args
142
+ )
143
+ force_tool_name = None
144
+ except IAToolkitException as e:
145
+ if (e.error_type == IAToolkitException.ErrorType.DATABASE_ERROR and
146
+ sql_retry_count < self.MAX_SQL_RETRIES):
147
+ sql_retry_count += 1
148
+ sql_query_with_error = args.get('query', 'No se pudo extraer la consulta.')
149
+ original_db_error = str(e.__cause__) if e.__cause__ else str(e)
150
+
151
+ logging.warning(
152
+ f"Error de SQL capturado, intentando corregir con el LLM (Intento {sql_retry_count}/{self.MAX_SQL_RETRIES}).")
153
+ result = self._create_sql_retry_prompt(function_name, sql_query_with_error, original_db_error)
154
+
155
+ # force the next call to be this function
156
+ force_tool_name = function_name
157
+ else:
158
+ error_message = f"Error en dispatch para '{function_name}' tras {sql_retry_count} reintentos: {str(e)}"
159
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
160
+ except Exception as e:
161
+ error_message = f"Dispatch error en {function_name} con args {args} -******- {str(e)}"
162
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
163
+
164
+ # add the return value into the list of messages
165
+ input_messages.append({
166
+ "type": "function_call_output",
167
+ "call_id": tool_call.call_id,
168
+ "output": str(result)
169
+ })
170
+ function_calls = True
171
+
172
+ # log the function call parameters and execution time in secs
173
+ elapsed = time.time() - fcall_time
174
+ f_call_identity = {function_name:args, 'time': f'{elapsed:.1f}' }
175
+ f_calls.append(f_call_identity)
176
+ f_call_time += elapsed
177
+
178
+ logging.info(f"end execution {function_name} in {elapsed:.1f} secs.")
179
+
180
+ if not function_calls:
181
+ break # no function call answer to send back to llm
182
+
183
+ # send results back to the LLM
184
+ tool_choice_value = "auto"
185
+ if force_tool_name:
186
+ tool_choice_value = "required"
187
+
188
+ response = llm_proxy.create_response(
189
+ model=self.model,
190
+ input=input_messages,
191
+ previous_response_id=response.id,
192
+ context_history=context_history,
193
+ reasoning=reasoning,
194
+ tool_choice=tool_choice_value,
195
+ tools=tools,
196
+ text=text
197
+ )
198
+ stats_fcall = self.add_stats(stats_fcall, self.get_stats(response))
199
+
200
+ # save the statistices
201
+ stats['response_time']=int(time.time() - start_time)
202
+ stats['sql_retry_count'] = sql_retry_count
203
+ stats['model'] = response.model
204
+
205
+ # decode the LLM response
206
+ decoded_response = self.decode_response(response)
207
+
208
+ # save the query and response
209
+ query = LLMQuery(user_identifier=user_identifier,
210
+ task_id=0,
211
+ company_id=company.id,
212
+ query=question,
213
+ output=decoded_response.get('answer', ''),
214
+ valid_response=decoded_response.get('status', False),
215
+ response=self.serialize_response(response, decoded_response),
216
+ function_calls=f_calls,
217
+ stats=self.add_stats(stats, stats_fcall),
218
+ answer_time=stats['response_time']
219
+ )
220
+ self.llmquery_repo.add_query(query)
221
+ logging.info(f"finish llm call in {int(time.time() - start_time)} secs..")
222
+ if function_calls:
223
+ logging.info(f"time within the function calls {f_call_time:.1f} secs.")
224
+
225
+ return {
226
+ 'valid_response': decoded_response.get('status', False),
227
+ 'answer': self.format_html(decoded_response.get('answer', '')),
228
+ 'stats': stats,
229
+ 'answer_format': decoded_response.get('answer_format', ''),
230
+ 'error_message': decoded_response.get('error_message', ''),
231
+ 'aditional_data': decoded_response.get('aditional_data', {}),
232
+ 'response_id': response.id,
233
+ 'query_id': query.id,
234
+ }
235
+ except SQLAlchemyError as db_error:
236
+ # rollback
237
+ self.llmquery_repo.session.rollback()
238
+ logging.error(f"Error de base de datos: {str(db_error)}")
239
+ raise db_error
240
+ except OperationalError as e:
241
+ logging.error(f"Operational error: {str(e)}")
242
+ raise e
243
+ except Exception as e:
244
+ error_message= str(e)
245
+
246
+ # log the error in the llm_query table
247
+ query = LLMQuery(user_identifier=user_identifier,
248
+ task_id=0,
249
+ company_id=company.id,
250
+ query=question,
251
+ output=error_message,
252
+ response=response.output_text if response else {},
253
+ valid_response=False,
254
+ function_calls=f_calls,
255
+ )
256
+ self.llmquery_repo.add_query(query)
257
+
258
+ # in case of context error
259
+ if "context_length_exceeded" in str(e):
260
+ error_message = CONTEXT_ERROR_MESSAGE
261
+ elif "string_above_max_length" in str(e):
262
+ error_message = 'La respuesta es muy extensa, trata de filtrar/restringuir tu consulta'
263
+
264
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
265
+
266
+ def set_company_context(self,
267
+ company: Company,
268
+ company_base_context: str,
269
+ model) -> str:
270
+
271
+ logging.info(f"initializing model '{model}' with company context: {self.count_tokens(company_base_context)} tokens...")
272
+
273
+ llm_proxy = self.llm_proxy_factory.create_for_company(company)
274
+ try:
275
+ response = llm_proxy.create_response(
276
+ model=model,
277
+ input=[{
278
+ "role": "system",
279
+ "content": company_base_context
280
+ }]
281
+ )
282
+
283
+ except Exception as e:
284
+ error_message = f"Error calling LLM API: {str(e)}"
285
+ logging.error(error_message)
286
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
287
+
288
+ return response.id
289
+
290
+ def decode_response(self, response) -> dict:
291
+ message = response.output_text
292
+ decoded_response = {
293
+ "status": False,
294
+ "output_text": message,
295
+ "answer": "",
296
+ "aditional_data": {},
297
+ "answer_format": "",
298
+ "error_message": ""
299
+ }
300
+
301
+ if response.status != 'completed':
302
+ decoded_response[
303
+ 'error_message'] = f'LLM ERROR {response.status}: no se completo tu pregunta, intenta de nuevo ...'
304
+ return decoded_response
305
+
306
+ if isinstance(message, dict):
307
+ if 'answer' not in message or 'aditional_data' not in message:
308
+ decoded_response['error_message'] = 'El llm respondio un diccionario invalido: missing "answer" key'
309
+ return decoded_response
310
+
311
+ decoded_response['status'] = True
312
+ decoded_response['answer'] = message.get('answer', '')
313
+ decoded_response['aditional_data'] = message.get('aditional_data', {})
314
+ decoded_response['answer_format'] = "dict"
315
+ return decoded_response
316
+
317
+ clean_message = re.sub(r'^\s*//.*$', '', message, flags=re.MULTILINE)
318
+
319
+ if not ('```json' in clean_message or clean_message.strip().startswith('{')):
320
+ decoded_response['status'] = True
321
+ decoded_response['answer'] = clean_message
322
+ decoded_response['answer_format'] = "plaintext"
323
+ return decoded_response
324
+
325
+ try:
326
+ # prepare the message for json load
327
+ json_string = clean_message.strip()
328
+ if json_string.startswith('```json'):
329
+ json_string = json_string[7:]
330
+ if json_string.endswith('```'):
331
+ json_string = json_string[:-3]
332
+
333
+ response_dict = json.loads(json_string.strip())
334
+ except Exception as e:
335
+ # --- ESTRATEGIA DE RESPALDO (FALLBACK) CON RESCATE DE DATOS ---
336
+ decoded_response['error_message'] = f'Error decodificando JSON: {str(e)}'
337
+
338
+ # Intenta rescatar el contenido de "answer" con una expresión regular más robusta.
339
+ # Este patrón busca "answer": "..." y captura todo hasta que encuentra "," y "aditional_data".
340
+ # re.DOTALL es crucial para que `.` coincida con los saltos de línea en el HTML.
341
+ match = re.search(r'"answer"\s*:\s*"(.*?)"\s*,\s*"aditional_data"', clean_message, re.DOTALL)
342
+
343
+ if match:
344
+ # ¡Éxito! Se encontró y extrajo el "answer".
345
+ # Se limpia el contenido de escapes JSON para obtener el HTML puro.
346
+ rescued_answer = match.group(1).replace('\\n', '\n').replace('\\"', '"')
347
+
348
+ decoded_response['status'] = True
349
+ decoded_response['answer'] = rescued_answer
350
+ decoded_response['answer_format'] = "plaintext_fallback_rescued"
351
+ else:
352
+ # Si la regex no encuentra nada, usar el texto completo como último recurso.
353
+ decoded_response['status'] = True
354
+ decoded_response['answer'] = clean_message
355
+ decoded_response['answer_format'] = "plaintext_fallback_full"
356
+ else:
357
+ # --- SOLO SE EJECUTA SI EL TRY FUE EXITOSO ---
358
+ if 'answer' not in response_dict or 'aditional_data' not in response_dict:
359
+ decoded_response['error_message'] = f'faltan las claves "answer" o "aditional_data" en el JSON'
360
+
361
+ # fallback
362
+ decoded_response['status'] = True
363
+ decoded_response['answer'] = str(response_dict)
364
+ decoded_response['answer_format'] = "json_fallback"
365
+ else:
366
+ # El diccionario JSON es perfecto.
367
+ decoded_response['status'] = True
368
+ decoded_response['answer'] = response_dict.get('answer', '')
369
+ decoded_response['aditional_data'] = response_dict.get('aditional_data', {})
370
+ decoded_response['answer_format'] = "json_string"
371
+
372
+ return decoded_response
373
+
374
+ def serialize_response(self, response, decoded_response):
375
+ response_dict = {
376
+ "format": decoded_response.get('answer_format', ''),
377
+ "error_message": decoded_response.get('error_message', ''),
378
+ "output": decoded_response.get('output_text', ''),
379
+ "id": response.id,
380
+ "model": response.model,
381
+ "status": response.status,
382
+ }
383
+ return response_dict
384
+
385
+ def get_stats(self, response):
386
+ stats_dict = {
387
+ "input_tokens": response.usage.input_tokens,
388
+ "output_tokens": response.usage.output_tokens,
389
+ "total_tokens": response.usage.total_tokens
390
+ }
391
+ return stats_dict
392
+
393
+ def add_stats(self, stats1: dict, stats2: dict) -> dict:
394
+ stats_dict = {
395
+ "input_tokens": stats1.get('input_tokens', 0) + stats2.get('input_tokens', 0),
396
+ "output_tokens": stats1.get('output_tokens', 0) + stats2.get('output_tokens', 0),
397
+ "total_tokens": stats1.get('total_tokens', 0) + stats2.get('total_tokens', 0),
398
+ }
399
+ return stats_dict
400
+
401
+
402
+ def _create_sql_retry_prompt(self, function_name: str, sql_query: str, db_error: str) -> str:
403
+ return f"""
404
+ ## ERROR DE EJECUCIÓN DE HERRAMIENTA
405
+
406
+ **Estado:** Fallido
407
+ **Herramienta:** `{function_name}`
408
+
409
+ La ejecución de la consulta SQL falló.
410
+
411
+ **Error específico de la base de datos:**
412
+ {db_error}
413
+ **Consulta SQL que causó el error:**
414
+ sql {sql_query}
415
+
416
+ **INSTRUCCIÓN OBLIGATORIA:**
417
+ 1. Analiza el error y corrige la sintaxis de la consulta SQL anterior.
418
+ 2. Llama a la herramienta `{function_name}` **OTRA VEZ**, inmediatamente, con la consulta corregida.
419
+ 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.
420
+ """
421
+
422
+ def format_html(self, answer: str):
423
+ html_answer = markdown2.markdown(answer).replace("\n", "")
424
+ return html_answer
425
+
426
+ def count_tokens(self, text):
427
+ # Codifica el texto y cuenta la cantidad de tokens
428
+ tokens = self.encoding.encode(text)
429
+ return len(tokens)
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from typing import Dict, List, Any
7
+ from abc import ABC, abstractmethod
8
+ from iatoolkit.common.util import Utility
9
+ from iatoolkit.infra.llm_response import LLMResponse
10
+ from iatoolkit.infra.openai_adapter import OpenAIAdapter
11
+ from iatoolkit.infra.gemini_adapter import GeminiAdapter
12
+ from iatoolkit.common.exceptions import IAToolkitException
13
+ from iatoolkit.repositories.models import Company
14
+ from openai import OpenAI
15
+ import google.generativeai as genai
16
+ import os
17
+ import threading
18
+ from enum import Enum
19
+ from injector import inject
20
+
21
+
22
+ class LLMProvider(Enum):
23
+ OPENAI = "openai"
24
+ GEMINI = "gemini"
25
+
26
+
27
+ class LLMAdapter(ABC):
28
+ """common interface for LLM adapters"""
29
+
30
+ @abstractmethod
31
+ def create_response(self, *args, **kwargs) -> LLMResponse:
32
+ pass
33
+
34
+
35
+ class LLMProxy:
36
+ """
37
+ Proxy que enruta las llamadas al adaptador correcto y gestiona la creación
38
+ de los clientes de los proveedores de LLM.
39
+ """
40
+ _clients_cache = {}
41
+ _clients_cache_lock = threading.Lock()
42
+
43
+ @inject
44
+ def __init__(self, util: Utility, openai_client = None, gemini_client = None):
45
+ """
46
+ Inicializa una instancia del proxy. Puede ser una instancia "base" (fábrica)
47
+ o una instancia de "trabajo" con clientes configurados.
48
+ """
49
+ self.util = util
50
+ self.openai_adapter = OpenAIAdapter(openai_client) if openai_client else None
51
+ self.gemini_adapter = GeminiAdapter(gemini_client) if gemini_client else None
52
+
53
+ def create_for_company(self, company: Company) -> 'LLMProxy':
54
+ """
55
+ Crea y configura una nueva instancia de LLMProxy para una empresa específica.
56
+ """
57
+ try:
58
+ openai_client = self._get_llm_connection(company, LLMProvider.OPENAI)
59
+ except IAToolkitException:
60
+ openai_client = None
61
+
62
+ try:
63
+ gemini_client = self._get_llm_connection(company, LLMProvider.GEMINI)
64
+ except IAToolkitException:
65
+ gemini_client = None
66
+
67
+ if not openai_client and not gemini_client:
68
+ raise IAToolkitException(
69
+ IAToolkitException.ErrorType.API_KEY,
70
+ f"La empresa '{company.name}' no tiene configuradas API keys para ningún proveedor LLM."
71
+ )
72
+
73
+ # Devuelve una NUEVA instancia con los clientes configurados
74
+ return LLMProxy(util=self.util, openai_client=openai_client, gemini_client=gemini_client)
75
+
76
+ def create_response(self, model: str, input: List[Dict], **kwargs) -> LLMResponse:
77
+ """Enruta la llamada al adaptador correcto basado en el modelo."""
78
+ # Se asume que esta instancia ya tiene los clientes configurados por `create_for_company`
79
+ if self.util.is_openai_model(model):
80
+ if not self.openai_adapter:
81
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
82
+ f"No se configuró cliente OpenAI, pero se solicitó modelo OpenAI: {model}")
83
+ return self.openai_adapter.create_response(model=model, input=input, **kwargs)
84
+ elif self.util.is_gemini_model(model):
85
+ if not self.gemini_adapter:
86
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
87
+ f"No se configuró cliente Gemini, pero se solicitó modelo Gemini: {model}")
88
+ return self.gemini_adapter.create_response(model=model, input=input, **kwargs)
89
+ else:
90
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, f"Modelo no soportado: {model}")
91
+
92
+ def _get_llm_connection(self, company: Company, provider: LLMProvider) -> Any:
93
+ """Obtiene una conexión de cliente para un proveedor, usando un caché para reutilizarla."""
94
+ cache_key = f"{company.short_name}_{provider.value}"
95
+ client = LLMProxy._clients_cache.get(cache_key)
96
+
97
+ if not client:
98
+ with LLMProxy._clients_cache_lock:
99
+ client = LLMProxy._clients_cache.get(cache_key)
100
+ if not client:
101
+ if provider == LLMProvider.OPENAI:
102
+ client = self._create_openai_client(company)
103
+ elif provider == LLMProvider.GEMINI:
104
+ client = self._create_gemini_client(company)
105
+ else:
106
+ raise IAToolkitException(f"Proveedor no soportado: {provider.value}")
107
+
108
+ if client:
109
+ LLMProxy._clients_cache[cache_key] = client
110
+
111
+ if not client:
112
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY, f"No se pudo crear el cliente para {provider.value}")
113
+
114
+ return client
115
+
116
+ def _create_openai_client(self, company: Company) -> OpenAI:
117
+ """Crea un cliente de OpenAI con la API key."""
118
+ if company.openai_api_key:
119
+ decrypted_api_key = self.util.decrypt_key(company.openai_api_key)
120
+ else:
121
+ decrypted_api_key = os.getenv("OPENAI_API_KEY", '')
122
+ if not decrypted_api_key:
123
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
124
+ f"La empresa '{company.name}' no tiene API key de OpenAI.")
125
+ return OpenAI(api_key=decrypted_api_key)
126
+
127
+ def _create_gemini_client(self, company: Company) -> Any:
128
+ """Configura y devuelve el cliente de Gemini."""
129
+
130
+ if company.gemini_api_key:
131
+ decrypted_api_key = self.util.decrypt_key(company.gemini_api_key)
132
+ else:
133
+ decrypted_api_key = os.getenv("GEMINI_API_KEY", '')
134
+
135
+ if not decrypted_api_key:
136
+ return None
137
+ genai.configure(api_key=decrypted_api_key)
138
+ return genai
139
+
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Dict, List, Any, Optional
8
+
9
+ @dataclass
10
+ class ToolCall:
11
+ """Representa una llamada a herramienta en formato común"""
12
+ call_id: str
13
+ type: str # 'function_call'
14
+ name: str
15
+ arguments: str
16
+
17
+
18
+ @dataclass
19
+ class Usage:
20
+ """Información de uso de tokens en formato común"""
21
+ input_tokens: int
22
+ output_tokens: int
23
+ total_tokens: int
24
+
25
+
26
+ @dataclass
27
+ class LLMResponse:
28
+ """Estructura común para respuestas de diferentes LLMs"""
29
+ id: str
30
+ model: str
31
+ status: str # 'completed', 'failed', etc.
32
+ output_text: str
33
+ output: List[ToolCall] # lista de tool calls
34
+ usage: Usage
35
+
36
+ def __post_init__(self):
37
+ """Asegura que output sea una lista"""
38
+ if self.output is None:
39
+ self.output = []
40
+