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.
- iatoolkit/__init__.py +27 -35
- iatoolkit/base_company.py +3 -35
- iatoolkit/cli_commands.py +18 -47
- iatoolkit/common/__init__.py +0 -0
- iatoolkit/common/exceptions.py +48 -0
- iatoolkit/common/interfaces/__init__.py +0 -0
- iatoolkit/common/interfaces/asset_storage.py +34 -0
- iatoolkit/common/interfaces/database_provider.py +39 -0
- iatoolkit/common/model_registry.py +159 -0
- iatoolkit/common/routes.py +138 -0
- iatoolkit/common/session_manager.py +26 -0
- iatoolkit/common/util.py +353 -0
- iatoolkit/company_registry.py +66 -29
- iatoolkit/core.py +514 -0
- iatoolkit/infra/__init__.py +5 -0
- iatoolkit/infra/brevo_mail_app.py +123 -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/google_chat_app.py +57 -0
- iatoolkit/infra/llm_providers/__init__.py +0 -0
- iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
- iatoolkit/infra/llm_providers/gemini_adapter.py +350 -0
- iatoolkit/infra/llm_providers/openai_adapter.py +124 -0
- iatoolkit/infra/llm_proxy.py +268 -0
- iatoolkit/infra/llm_response.py +45 -0
- iatoolkit/infra/redis_session_manager.py +122 -0
- iatoolkit/locales/en.yaml +222 -0
- iatoolkit/locales/es.yaml +225 -0
- iatoolkit/repositories/__init__.py +5 -0
- iatoolkit/repositories/database_manager.py +187 -0
- iatoolkit/repositories/document_repo.py +33 -0
- iatoolkit/repositories/filesystem_asset_repository.py +36 -0
- iatoolkit/repositories/llm_query_repo.py +105 -0
- iatoolkit/repositories/models.py +279 -0
- iatoolkit/repositories/profile_repo.py +171 -0
- iatoolkit/repositories/vs_repo.py +150 -0
- iatoolkit/services/__init__.py +5 -0
- iatoolkit/services/auth_service.py +193 -0
- {services → iatoolkit/services}/benchmark_service.py +7 -7
- iatoolkit/services/branding_service.py +153 -0
- iatoolkit/services/company_context_service.py +214 -0
- iatoolkit/services/configuration_service.py +375 -0
- iatoolkit/services/dispatcher_service.py +134 -0
- {services → iatoolkit/services}/document_service.py +20 -8
- iatoolkit/services/embedding_service.py +148 -0
- iatoolkit/services/excel_service.py +156 -0
- {services → iatoolkit/services}/file_processor_service.py +36 -21
- iatoolkit/services/history_manager_service.py +208 -0
- iatoolkit/services/i18n_service.py +104 -0
- iatoolkit/services/jwt_service.py +80 -0
- iatoolkit/services/language_service.py +89 -0
- iatoolkit/services/license_service.py +82 -0
- iatoolkit/services/llm_client_service.py +438 -0
- iatoolkit/services/load_documents_service.py +174 -0
- iatoolkit/services/mail_service.py +213 -0
- {services → iatoolkit/services}/profile_service.py +200 -101
- iatoolkit/services/prompt_service.py +303 -0
- iatoolkit/services/query_service.py +467 -0
- iatoolkit/services/search_service.py +55 -0
- iatoolkit/services/sql_service.py +169 -0
- iatoolkit/services/tool_service.py +246 -0
- iatoolkit/services/user_feedback_service.py +117 -0
- iatoolkit/services/user_session_context_service.py +213 -0
- iatoolkit/static/images/fernando.jpeg +0 -0
- iatoolkit/static/images/iatoolkit_core.png +0 -0
- iatoolkit/static/images/iatoolkit_logo.png +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 +110 -0
- iatoolkit/static/js/chat_logout_button.js +36 -0
- iatoolkit/static/js/chat_main.js +401 -0
- iatoolkit/static/js/chat_model_selector.js +227 -0
- iatoolkit/static/js/chat_onboarding_button.js +103 -0
- iatoolkit/static/js/chat_prompt_manager.js +94 -0
- iatoolkit/static/js/chat_reload_button.js +38 -0
- iatoolkit/static/styles/chat_iatoolkit.css +559 -0
- iatoolkit/static/styles/chat_modal.css +133 -0
- iatoolkit/static/styles/chat_public.css +135 -0
- iatoolkit/static/styles/documents.css +598 -0
- iatoolkit/static/styles/landing_page.css +398 -0
- iatoolkit/static/styles/llm_output.css +148 -0
- iatoolkit/static/styles/onboarding.css +176 -0
- iatoolkit/system_prompts/__init__.py +0 -0
- iatoolkit/system_prompts/query_main.prompt +30 -23
- iatoolkit/system_prompts/sql_rules.prompt +47 -12
- iatoolkit/templates/_company_header.html +45 -0
- iatoolkit/templates/_login_widget.html +42 -0
- iatoolkit/templates/base.html +78 -0
- iatoolkit/templates/change_password.html +66 -0
- iatoolkit/templates/chat.html +337 -0
- iatoolkit/templates/chat_modals.html +185 -0
- iatoolkit/templates/error.html +51 -0
- iatoolkit/templates/forgot_password.html +51 -0
- iatoolkit/templates/onboarding_shell.html +106 -0
- iatoolkit/templates/signup.html +79 -0
- iatoolkit/views/__init__.py +5 -0
- iatoolkit/views/base_login_view.py +96 -0
- iatoolkit/views/change_password_view.py +116 -0
- iatoolkit/views/chat_view.py +76 -0
- iatoolkit/views/embedding_api_view.py +65 -0
- iatoolkit/views/forgot_password_view.py +75 -0
- iatoolkit/views/help_content_api_view.py +54 -0
- iatoolkit/views/history_api_view.py +56 -0
- iatoolkit/views/home_view.py +63 -0
- iatoolkit/views/init_context_api_view.py +74 -0
- iatoolkit/views/llmquery_api_view.py +59 -0
- iatoolkit/views/load_company_configuration_api_view.py +49 -0
- iatoolkit/views/load_document_api_view.py +65 -0
- iatoolkit/views/login_view.py +170 -0
- iatoolkit/views/logout_api_view.py +57 -0
- iatoolkit/views/profile_api_view.py +46 -0
- iatoolkit/views/prompt_api_view.py +37 -0
- iatoolkit/views/root_redirect_view.py +22 -0
- iatoolkit/views/signup_view.py +100 -0
- iatoolkit/views/static_page_view.py +27 -0
- iatoolkit/views/user_feedback_api_view.py +60 -0
- iatoolkit/views/users_api_view.py +33 -0
- iatoolkit/views/verify_user_view.py +60 -0
- iatoolkit-0.107.4.dist-info/METADATA +268 -0
- iatoolkit-0.107.4.dist-info/RECORD +132 -0
- iatoolkit-0.107.4.dist-info/licenses/LICENSE +21 -0
- iatoolkit-0.107.4.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
- {iatoolkit-0.3.9.dist-info → iatoolkit-0.107.4.dist-info}/top_level.txt +0 -1
- iatoolkit/iatoolkit.py +0 -413
- iatoolkit/system_prompts/arquitectura.prompt +0 -32
- iatoolkit-0.3.9.dist-info/METADATA +0 -252
- iatoolkit-0.3.9.dist-info/RECORD +0 -32
- services/__init__.py +0 -5
- services/api_service.py +0 -75
- services/dispatcher_service.py +0 -351
- services/excel_service.py +0 -98
- services/history_service.py +0 -45
- services/jwt_service.py +0 -91
- services/load_documents_service.py +0 -212
- services/mail_service.py +0 -62
- services/prompt_manager_service.py +0 -172
- services/query_service.py +0 -334
- services/search_service.py +0 -32
- services/sql_service.py +0 -42
- services/tasks_service.py +0 -188
- services/user_feedback_service.py +0 -67
- services/user_session_context_service.py +0 -85
- {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}")
|