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.
- iatoolkit/__init__.py +13 -35
- iatoolkit/base_company.py +74 -8
- iatoolkit/cli_commands.py +15 -23
- iatoolkit/common/__init__.py +0 -0
- iatoolkit/common/exceptions.py +46 -0
- iatoolkit/common/routes.py +141 -0
- iatoolkit/common/session_manager.py +24 -0
- iatoolkit/common/util.py +348 -0
- iatoolkit/company_registry.py +7 -8
- iatoolkit/iatoolkit.py +169 -96
- iatoolkit/infra/__init__.py +5 -0
- iatoolkit/infra/call_service.py +140 -0
- iatoolkit/infra/connectors/__init__.py +5 -0
- iatoolkit/infra/connectors/file_connector.py +17 -0
- iatoolkit/infra/connectors/file_connector_factory.py +57 -0
- iatoolkit/infra/connectors/google_cloud_storage_connector.py +53 -0
- iatoolkit/infra/connectors/google_drive_connector.py +68 -0
- iatoolkit/infra/connectors/local_file_connector.py +46 -0
- iatoolkit/infra/connectors/s3_connector.py +33 -0
- iatoolkit/infra/gemini_adapter.py +356 -0
- iatoolkit/infra/google_chat_app.py +57 -0
- iatoolkit/infra/llm_client.py +429 -0
- iatoolkit/infra/llm_proxy.py +139 -0
- iatoolkit/infra/llm_response.py +40 -0
- iatoolkit/infra/mail_app.py +145 -0
- iatoolkit/infra/openai_adapter.py +90 -0
- iatoolkit/infra/redis_session_manager.py +122 -0
- iatoolkit/locales/en.yaml +144 -0
- iatoolkit/locales/es.yaml +140 -0
- iatoolkit/repositories/__init__.py +5 -0
- iatoolkit/repositories/database_manager.py +110 -0
- iatoolkit/repositories/document_repo.py +33 -0
- iatoolkit/repositories/llm_query_repo.py +91 -0
- iatoolkit/repositories/models.py +336 -0
- iatoolkit/repositories/profile_repo.py +123 -0
- iatoolkit/repositories/tasks_repo.py +52 -0
- iatoolkit/repositories/vs_repo.py +139 -0
- iatoolkit/services/__init__.py +5 -0
- iatoolkit/services/auth_service.py +193 -0
- {services → iatoolkit/services}/benchmark_service.py +6 -6
- iatoolkit/services/branding_service.py +149 -0
- {services → iatoolkit/services}/dispatcher_service.py +39 -99
- {services → iatoolkit/services}/document_service.py +5 -5
- {services → iatoolkit/services}/excel_service.py +27 -21
- {services → iatoolkit/services}/file_processor_service.py +5 -5
- iatoolkit/services/help_content_service.py +30 -0
- {services → iatoolkit/services}/history_service.py +8 -16
- iatoolkit/services/i18n_service.py +104 -0
- {services → iatoolkit/services}/jwt_service.py +18 -27
- iatoolkit/services/language_service.py +77 -0
- {services → iatoolkit/services}/load_documents_service.py +19 -14
- {services → iatoolkit/services}/mail_service.py +5 -5
- iatoolkit/services/onboarding_service.py +43 -0
- {services → iatoolkit/services}/profile_service.py +155 -89
- {services → iatoolkit/services}/prompt_manager_service.py +26 -11
- {services → iatoolkit/services}/query_service.py +142 -104
- {services → iatoolkit/services}/search_service.py +21 -5
- {services → iatoolkit/services}/sql_service.py +24 -6
- {services → iatoolkit/services}/tasks_service.py +10 -10
- iatoolkit/services/user_feedback_service.py +103 -0
- iatoolkit/services/user_session_context_service.py +143 -0
- iatoolkit/static/images/fernando.jpeg +0 -0
- iatoolkit/static/js/chat_feedback_button.js +80 -0
- iatoolkit/static/js/chat_filepond.js +85 -0
- iatoolkit/static/js/chat_help_content.js +124 -0
- iatoolkit/static/js/chat_history_button.js +112 -0
- iatoolkit/static/js/chat_logout_button.js +36 -0
- iatoolkit/static/js/chat_main.js +364 -0
- iatoolkit/static/js/chat_onboarding_button.js +97 -0
- iatoolkit/static/js/chat_prompt_manager.js +94 -0
- iatoolkit/static/js/chat_reload_button.js +35 -0
- iatoolkit/static/styles/chat_iatoolkit.css +592 -0
- iatoolkit/static/styles/chat_modal.css +169 -0
- iatoolkit/static/styles/chat_public.css +107 -0
- iatoolkit/static/styles/landing_page.css +182 -0
- iatoolkit/static/styles/llm_output.css +115 -0
- iatoolkit/static/styles/onboarding.css +169 -0
- iatoolkit/system_prompts/query_main.prompt +5 -15
- iatoolkit/templates/_company_header.html +20 -0
- iatoolkit/templates/_login_widget.html +42 -0
- iatoolkit/templates/about.html +13 -0
- iatoolkit/templates/base.html +65 -0
- iatoolkit/templates/change_password.html +66 -0
- iatoolkit/templates/chat.html +287 -0
- iatoolkit/templates/chat_modals.html +181 -0
- iatoolkit/templates/error.html +51 -0
- iatoolkit/templates/forgot_password.html +50 -0
- iatoolkit/templates/index.html +145 -0
- iatoolkit/templates/login_simulation.html +34 -0
- iatoolkit/templates/onboarding_shell.html +104 -0
- iatoolkit/templates/signup.html +76 -0
- iatoolkit/views/__init__.py +5 -0
- iatoolkit/views/base_login_view.py +92 -0
- iatoolkit/views/change_password_view.py +117 -0
- iatoolkit/views/external_login_view.py +73 -0
- iatoolkit/views/file_store_api_view.py +65 -0
- iatoolkit/views/forgot_password_view.py +72 -0
- iatoolkit/views/help_content_api_view.py +54 -0
- iatoolkit/views/history_api_view.py +56 -0
- iatoolkit/views/home_view.py +61 -0
- iatoolkit/views/index_view.py +14 -0
- iatoolkit/views/init_context_api_view.py +73 -0
- iatoolkit/views/llmquery_api_view.py +57 -0
- iatoolkit/views/login_simulation_view.py +81 -0
- iatoolkit/views/login_view.py +153 -0
- iatoolkit/views/logout_api_view.py +49 -0
- iatoolkit/views/profile_api_view.py +46 -0
- iatoolkit/views/prompt_api_view.py +37 -0
- iatoolkit/views/signup_view.py +94 -0
- iatoolkit/views/tasks_api_view.py +72 -0
- iatoolkit/views/tasks_review_api_view.py +55 -0
- iatoolkit/views/user_feedback_api_view.py +60 -0
- iatoolkit/views/verify_user_view.py +62 -0
- {iatoolkit-0.4.2.dist-info → iatoolkit-0.66.2.dist-info}/METADATA +2 -2
- iatoolkit-0.66.2.dist-info/RECORD +119 -0
- {iatoolkit-0.4.2.dist-info → iatoolkit-0.66.2.dist-info}/top_level.txt +0 -1
- iatoolkit/system_prompts/arquitectura.prompt +0 -32
- iatoolkit-0.4.2.dist-info/RECORD +0 -32
- services/__init__.py +0 -5
- services/api_service.py +0 -75
- services/user_feedback_service.py +0 -67
- services/user_session_context_service.py +0 -85
- {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
|
+
|