iatoolkit 0.7.2__tar.gz → 0.7.5__tar.gz
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-0.7.2 → iatoolkit-0.7.5}/PKG-INFO +1 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/pyproject.toml +2 -2
- iatoolkit-0.7.5/src/common/__init__.py +0 -0
- iatoolkit-0.7.5/src/common/auth.py +200 -0
- iatoolkit-0.7.5/src/common/exceptions.py +46 -0
- iatoolkit-0.7.5/src/common/routes.py +86 -0
- iatoolkit-0.7.5/src/common/session_manager.py +25 -0
- iatoolkit-0.7.5/src/common/util.py +358 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/cli_commands.py +0 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/iatoolkit.py +4 -4
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit.egg-info/PKG-INFO +1 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit.egg-info/SOURCES.txt +6 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit.egg-info/top_level.txt +1 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/dispatcher_service.py +0 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/prompt_manager_service.py +0 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/query_service.py +1 -1
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/readme.md +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/requirements.txt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/setup.cfg +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/__init__.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/base_company.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/company_registry.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/system_prompts/format_styles.prompt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/system_prompts/query_main.prompt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit/system_prompts/sql_rules.prompt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit.egg-info/dependency_links.txt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/iatoolkit.egg-info/requires.txt +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/__init__.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/benchmark_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/document_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/excel_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/file_processor_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/history_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/jwt_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/load_documents_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/mail_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/profile_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/search_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/sql_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/tasks_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/user_feedback_service.py +0 -0
- {iatoolkit-0.7.2 → iatoolkit-0.7.5}/src/services/user_session_context_service.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iatoolkit"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.5"
|
|
8
8
|
requires-python = ">=3.11"
|
|
9
9
|
description = "IAToolkit"
|
|
10
10
|
readme = "readme.md"
|
|
@@ -19,7 +19,7 @@ package-dir = {"" = "src"}
|
|
|
19
19
|
|
|
20
20
|
[tool.setuptools.packages.find]
|
|
21
21
|
where = ["src"]
|
|
22
|
-
include = ["iatoolkit*", "services*"]
|
|
22
|
+
include = ["iatoolkit*", "services*", "common*"]
|
|
23
23
|
|
|
24
24
|
[tool.setuptools.dynamic]
|
|
25
25
|
dependencies = { file = ["requirements.txt"] }
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
from flask import redirect, url_for
|
|
7
|
+
from common.session_manager import SessionManager
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from injector import inject
|
|
10
|
+
from repositories.profile_repo import ProfileRepo
|
|
11
|
+
from services.jwt_service import JWTService
|
|
12
|
+
import logging
|
|
13
|
+
from flask import request
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
MAX_INACTIVITY_SECONDS = 60*30
|
|
17
|
+
|
|
18
|
+
class IAuthentication:
|
|
19
|
+
@inject
|
|
20
|
+
|
|
21
|
+
def __init__(self,
|
|
22
|
+
profile_repo: ProfileRepo,
|
|
23
|
+
jwt_service: JWTService):
|
|
24
|
+
self.profile_repo = profile_repo
|
|
25
|
+
self.jwt_service = jwt_service
|
|
26
|
+
|
|
27
|
+
def verify(self, company_short_name: str, body_external_user_id: str = None) -> dict:
|
|
28
|
+
# authentication is in this orden: JWT, API Key, Sesión
|
|
29
|
+
local_user_id = None
|
|
30
|
+
company_id = None
|
|
31
|
+
auth_method = None
|
|
32
|
+
external_user_id = None # for JWT or API Key
|
|
33
|
+
|
|
34
|
+
# 1. try auth via JWT
|
|
35
|
+
jwt_company_id, jwt_external_user_id, jwt_error_info = self._authenticate_via_chat_jwt(company_short_name)
|
|
36
|
+
|
|
37
|
+
if jwt_company_id is not None and jwt_external_user_id is not None:
|
|
38
|
+
auth_method = "JWT"
|
|
39
|
+
company_id = jwt_company_id
|
|
40
|
+
external_user_id = jwt_external_user_id
|
|
41
|
+
local_user_id = 0
|
|
42
|
+
elif jwt_error_info is not None:
|
|
43
|
+
# explicit error in JWT (inválido, expirado, etc.)
|
|
44
|
+
logging.warning(f"Fallo de autenticación JWT: {jwt_error_info}")
|
|
45
|
+
return {"error_message": "Fallo de autenticación JWT"}
|
|
46
|
+
else:
|
|
47
|
+
# 2. JWT not apply, try by API Key
|
|
48
|
+
api_key_company_id, api_key_error_info = self._authenticate_via_api_key(company_short_name)
|
|
49
|
+
|
|
50
|
+
if api_key_company_id is not None:
|
|
51
|
+
auth_method = "API Key"
|
|
52
|
+
company_id = api_key_company_id
|
|
53
|
+
external_user_id = body_external_user_id # API Key usa external_user_id del body
|
|
54
|
+
local_user_id = 0
|
|
55
|
+
elif api_key_error_info is not None:
|
|
56
|
+
# explicit error in API Key (inválida, incorrecta, error interno)
|
|
57
|
+
logging.warning(f"Fallo de autenticación API Key: {api_key_error_info}")
|
|
58
|
+
return {"error_message": "Fallo de autenticación API Key"}
|
|
59
|
+
else:
|
|
60
|
+
# 3. no JWT and API Key auth, try by Session
|
|
61
|
+
self.check_if_user_is_logged_in(company_short_name) # raise exception or redirect if not logged in
|
|
62
|
+
|
|
63
|
+
# In case not logged in check_if_user_is_logged_in redirects to login page
|
|
64
|
+
auth_method = "Session"
|
|
65
|
+
local_user_id = SessionManager.get('user_id')
|
|
66
|
+
company_id = SessionManager.get('company_id')
|
|
67
|
+
external_user_id = ""
|
|
68
|
+
|
|
69
|
+
if not company_id or not local_user_id:
|
|
70
|
+
logging.error(
|
|
71
|
+
f"Sesión válida para {company_short_name} pero falta company_id o user_id en SessionManager.")
|
|
72
|
+
return {"error_message": "Fallo interno en la autenticación o no autenticado"}
|
|
73
|
+
|
|
74
|
+
# last verification of authentication
|
|
75
|
+
if company_id is None or auth_method is None or local_user_id is None:
|
|
76
|
+
# this condition should never happen,
|
|
77
|
+
logging.error(
|
|
78
|
+
f"Fallo inesperado en la lógica de autenticación para {company_short_name}. Ningún método tuvo éxito o devolvió error.")
|
|
79
|
+
return {"error_message": "Fallo interno en la autenticación o no autenticado"}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
'success': True,
|
|
83
|
+
"auth_method": auth_method,
|
|
84
|
+
"company_id": company_id,
|
|
85
|
+
"auth_method": auth_method,
|
|
86
|
+
"local_user_id": local_user_id,
|
|
87
|
+
"external_user_id": external_user_id
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _authenticate_via_api_key(self, company_short_name_from_url: str):
|
|
91
|
+
"""
|
|
92
|
+
try to authenticate using an API Key from the header 'Authorization'.
|
|
93
|
+
Retorna (company_id, None) en éxito.
|
|
94
|
+
Retorna (None, error_message) en fallo.
|
|
95
|
+
"""
|
|
96
|
+
api_key_header = request.headers.get('Authorization')
|
|
97
|
+
api_key_value = None
|
|
98
|
+
|
|
99
|
+
# extract the key
|
|
100
|
+
if api_key_header and api_key_header.startswith('Bearer '):
|
|
101
|
+
api_key_value = api_key_header.split('Bearer ')[1]
|
|
102
|
+
else:
|
|
103
|
+
# there is no key in the headers expected
|
|
104
|
+
return None, None
|
|
105
|
+
|
|
106
|
+
# validate the api-key using ProfileRepo
|
|
107
|
+
try:
|
|
108
|
+
api_key_entry = self.profile_repo.get_active_api_key_entry(api_key_value)
|
|
109
|
+
if not api_key_entry:
|
|
110
|
+
logging.warning(f"Intento de acceso con API Key inválida o inactiva: {api_key_value[:5]}...")
|
|
111
|
+
return None, "API Key inválida o inactiva"
|
|
112
|
+
|
|
113
|
+
# check that the key belongs to the company
|
|
114
|
+
# api_key_entry.company already loaded by joinedload
|
|
115
|
+
if not api_key_entry.company or api_key_entry.company.short_name != company_short_name_from_url:
|
|
116
|
+
return None, f"API Key no es válida para la compañía {company_short_name_from_url}"
|
|
117
|
+
|
|
118
|
+
# successfull auth by API Key
|
|
119
|
+
company_id = api_key_entry.company_id
|
|
120
|
+
|
|
121
|
+
return company_id, None
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logging.exception(f"Error interno durante validación de API Key: {e}")
|
|
125
|
+
return None, "Error interno del servidor al validar API Key"
|
|
126
|
+
|
|
127
|
+
def _authenticate_via_chat_jwt(self, company_short_name_from_url: str) -> tuple[
|
|
128
|
+
Optional[int], Optional[str], Optional[str]]:
|
|
129
|
+
"""
|
|
130
|
+
authenticate using an JWT chat session in the del header 'X-Chat-Token'.
|
|
131
|
+
Return (company_id, external_user_id, None) on exit
|
|
132
|
+
Returns (None, None, error_message) on fail.
|
|
133
|
+
"""
|
|
134
|
+
chat_jwt = request.headers.get('X-Chat-Token')
|
|
135
|
+
if not chat_jwt:
|
|
136
|
+
return None, None, None
|
|
137
|
+
|
|
138
|
+
# open the jwt token and retrieve the payload
|
|
139
|
+
jwt_payload = self.jwt_service.validate_chat_jwt(chat_jwt, company_short_name_from_url)
|
|
140
|
+
if not jwt_payload:
|
|
141
|
+
# validation fails (token expired, incorrect signature, company , etc.)
|
|
142
|
+
# validate_chat_jwt logs the specific failure
|
|
143
|
+
return None, None, "Token de chat expirado, debes reingresar al chat"
|
|
144
|
+
|
|
145
|
+
# JWT is validated: extract the company_id and external_user_id
|
|
146
|
+
company_id = jwt_payload.get('company_id')
|
|
147
|
+
external_user_id = jwt_payload.get('external_user_id')
|
|
148
|
+
|
|
149
|
+
# Sanity check aditional, should never happen
|
|
150
|
+
if not isinstance(company_id, int) or not external_user_id:
|
|
151
|
+
logging.error(
|
|
152
|
+
f"LLMQuery: JWT payload incompleto tras validación exitosa. CompanyID: {company_id}, UserID: {external_user_id}")
|
|
153
|
+
return None, None, "Token de chat con formato interno incorrecto"
|
|
154
|
+
|
|
155
|
+
return company_id, external_user_id, None
|
|
156
|
+
|
|
157
|
+
def check_if_user_is_logged_in(self, company_short_name: str):
|
|
158
|
+
if not SessionManager.get('user'):
|
|
159
|
+
if company_short_name:
|
|
160
|
+
return redirect(url_for('login', company_short_name=company_short_name))
|
|
161
|
+
else:
|
|
162
|
+
return redirect(url_for('home'))
|
|
163
|
+
|
|
164
|
+
if company_short_name != SessionManager.get('company_short_name'):
|
|
165
|
+
return redirect(url_for('login', company_short_name=company_short_name))
|
|
166
|
+
|
|
167
|
+
# check session timeout
|
|
168
|
+
if not self.check_session_timeout():
|
|
169
|
+
SessionManager.clear()
|
|
170
|
+
return redirect(url_for('login', company_short_name=company_short_name))
|
|
171
|
+
|
|
172
|
+
# update last_activity
|
|
173
|
+
SessionManager.set('last_activity', datetime.now(timezone.utc).timestamp())
|
|
174
|
+
|
|
175
|
+
def check_session_timeout(self):
|
|
176
|
+
# get last activity from session manager
|
|
177
|
+
last_activity = SessionManager.get('last_activity')
|
|
178
|
+
if not last_activity:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
# Tiempo actual en timestamp
|
|
182
|
+
current_time = datetime.now(timezone.utc).timestamp()
|
|
183
|
+
|
|
184
|
+
# get inactivity duration
|
|
185
|
+
inactivity_duration = current_time - last_activity
|
|
186
|
+
|
|
187
|
+
# verify if inactivity duration is greater than MAX_INACTIVITY_SECONDS
|
|
188
|
+
if inactivity_duration > MAX_INACTIVITY_SECONDS:
|
|
189
|
+
# close session
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# update last activity timestamp
|
|
193
|
+
SessionManager.set('last_activity', current_time)
|
|
194
|
+
|
|
195
|
+
return True # session is active
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IAToolkitException(Exception):
|
|
10
|
+
|
|
11
|
+
class ErrorType(Enum):
|
|
12
|
+
SYSTEM_ERROR = 0
|
|
13
|
+
DATABASE_ERROR = 1
|
|
14
|
+
LLM_ERROR = 2
|
|
15
|
+
CLOUD_STORAGE_ERROR = 3
|
|
16
|
+
DOCUMENT_NOT_FOUND = 4
|
|
17
|
+
INVALID_PARAMETER = 5
|
|
18
|
+
MISSING_PARAMETER = 6
|
|
19
|
+
PARAM_NOT_FILLED = 7
|
|
20
|
+
PERMISSION = 8
|
|
21
|
+
EXIST = 9
|
|
22
|
+
API_KEY = 10
|
|
23
|
+
CALL_ERROR = 11
|
|
24
|
+
PROMPT_ERROR = 12
|
|
25
|
+
FILE_FORMAT_ERROR = 13
|
|
26
|
+
FILE_IO_ERROR = 14
|
|
27
|
+
TEMPLATE_ERROR = 15
|
|
28
|
+
EXTERNAL_SOURCE_ERROR = 16
|
|
29
|
+
MAIL_ERROR = 17
|
|
30
|
+
CONFIG_ERROR = 18
|
|
31
|
+
INVALID_NAME = 19
|
|
32
|
+
REQUEST_ERROR = 20
|
|
33
|
+
TASK_EXECUTION_ERROR = 21
|
|
34
|
+
TASK_NOT_FOUND = 22
|
|
35
|
+
INVALID_STATE = 23
|
|
36
|
+
CRYPT_ERROR = 24
|
|
37
|
+
LOAD_DOCUMENT_ERROR = 25
|
|
38
|
+
INVALID_USER = 26
|
|
39
|
+
VECTOR_STORE_ERROR = 27
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def __init__(self, error_type: ErrorType = ErrorType.SYSTEM_ERROR, message=None):
|
|
44
|
+
self.error_type = error_type
|
|
45
|
+
self.message = message
|
|
46
|
+
super().__init__(self.message)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
from flask import render_template, redirect, flash, url_for,send_from_directory, current_app
|
|
7
|
+
from common.session_manager import SessionManager
|
|
8
|
+
from flask import jsonify
|
|
9
|
+
from views.history_view import HistoryView
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def logout(company_short_name: str):
|
|
14
|
+
SessionManager.clear()
|
|
15
|
+
flash("Has cerrado sesión correctamente", "info")
|
|
16
|
+
if company_short_name:
|
|
17
|
+
return redirect(url_for('login', company_short_name=company_short_name))
|
|
18
|
+
else:
|
|
19
|
+
return redirect(url_for('home'))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# this function register all the views
|
|
23
|
+
def register_views(injector, app):
|
|
24
|
+
|
|
25
|
+
from views.llmquery_view import LLMQueryView
|
|
26
|
+
from views.tasks_view import TaskView
|
|
27
|
+
from views.tasks_review_view import TaskReviewView
|
|
28
|
+
from views.home_view import HomeView
|
|
29
|
+
from views.chat_view import ChatView
|
|
30
|
+
from views.login_view import LoginView
|
|
31
|
+
from views.external_chat_login_view import ExternalChatLoginView
|
|
32
|
+
from views.signup_view import SignupView
|
|
33
|
+
from views.verify_user_view import VerifyAccountView
|
|
34
|
+
from views.forgot_password_view import ForgotPasswordView
|
|
35
|
+
from views.change_password_view import ChangePasswordView
|
|
36
|
+
from views.file_store_view import FileStoreView
|
|
37
|
+
from views.user_feedback_view import UserFeedbackView
|
|
38
|
+
from views.prompt_view import PromptView
|
|
39
|
+
from views.chat_token_request_view import ChatTokenRequestView
|
|
40
|
+
from views.external_login_view import ExternalLoginView
|
|
41
|
+
from views.download_file_view import DownloadFileView
|
|
42
|
+
|
|
43
|
+
app.add_url_rule('/', view_func=HomeView.as_view('home'))
|
|
44
|
+
|
|
45
|
+
# main chat for iatoolkit front
|
|
46
|
+
app.add_url_rule('/<company_short_name>/chat', view_func=ChatView.as_view('chat'))
|
|
47
|
+
|
|
48
|
+
# front if the company internal portal
|
|
49
|
+
app.add_url_rule('/<company_short_name>/chat_login', view_func=ExternalChatLoginView.as_view('external_chat_login'))
|
|
50
|
+
app.add_url_rule('/<company_short_name>/external_login/<external_user_id>', view_func=ExternalLoginView.as_view('external_login'))
|
|
51
|
+
app.add_url_rule('/auth/chat_token', view_func=ChatTokenRequestView.as_view('chat-token'))
|
|
52
|
+
|
|
53
|
+
# main pages for the iatoolkit frontend
|
|
54
|
+
app.add_url_rule('/<company_short_name>/login', view_func=LoginView.as_view('login'))
|
|
55
|
+
app.add_url_rule('/<company_short_name>/signup',view_func=SignupView.as_view('signup'))
|
|
56
|
+
app.add_url_rule('/<company_short_name>/logout', 'logout', logout)
|
|
57
|
+
app.add_url_rule('/logout', 'logout', logout)
|
|
58
|
+
app.add_url_rule('/<company_short_name>/verify/<token>', view_func=VerifyAccountView.as_view('verify_account'))
|
|
59
|
+
app.add_url_rule('/<company_short_name>/forgot-password', view_func=ForgotPasswordView.as_view('forgot_password'))
|
|
60
|
+
app.add_url_rule('/<company_short_name>/change-password/<token>', view_func=ChangePasswordView.as_view('change_password'))
|
|
61
|
+
|
|
62
|
+
# this are backend endpoints mainly
|
|
63
|
+
app.add_url_rule('/<company_short_name>/llm_query', view_func=LLMQueryView.as_view('llm_query'))
|
|
64
|
+
app.add_url_rule('/<company_short_name>/feedback', view_func=UserFeedbackView.as_view('feedback'))
|
|
65
|
+
app.add_url_rule('/<company_short_name>/prompts', view_func=PromptView.as_view('prompt'))
|
|
66
|
+
app.add_url_rule('/<company_short_name>/history', view_func=HistoryView.as_view('history'))
|
|
67
|
+
app.add_url_rule('/tasks', view_func=TaskView.as_view('tasks'))
|
|
68
|
+
app.add_url_rule('/tasks/review/<int:task_id>', view_func=TaskReviewView.as_view('tasks-review'))
|
|
69
|
+
app.add_url_rule('/load', view_func=FileStoreView.as_view('load'))
|
|
70
|
+
|
|
71
|
+
app.add_url_rule(
|
|
72
|
+
'/about', # URL de la ruta
|
|
73
|
+
view_func=lambda: render_template('about.html'))
|
|
74
|
+
|
|
75
|
+
app.add_url_rule('/version', 'version',
|
|
76
|
+
lambda: jsonify({"version": app.config['VERSION']}))
|
|
77
|
+
|
|
78
|
+
app.add_url_rule('/<company_short_name>/<external_user_id>/download-file/<path:filename>',
|
|
79
|
+
view_func=DownloadFileView.as_view('download-file'))
|
|
80
|
+
|
|
81
|
+
@app.route('/download/<path:filename>')
|
|
82
|
+
def download_file(filename):
|
|
83
|
+
temp_dir = os.path.join(current_app.root_path, 'static', 'temp')
|
|
84
|
+
return send_from_directory(temp_dir, filename, as_attachment=True)
|
|
85
|
+
|
|
86
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
from flask import session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionManager:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def set(key, value):
|
|
12
|
+
session[key] = value
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def get(key, default=None):
|
|
16
|
+
return session.get(key, default)
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def remove(key):
|
|
20
|
+
if key in session:
|
|
21
|
+
session.pop(key)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def clear():
|
|
25
|
+
session.clear()
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import List
|
|
8
|
+
from common.exceptions import IAToolkitException
|
|
9
|
+
from injector import inject
|
|
10
|
+
import os
|
|
11
|
+
from jinja2 import Environment, FileSystemLoader
|
|
12
|
+
from common.session_manager import SessionManager
|
|
13
|
+
from datetime import datetime, date
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
import yaml
|
|
16
|
+
from cryptography.fernet import Fernet
|
|
17
|
+
import base64
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Utility:
|
|
21
|
+
@inject
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.encryption_key = os.getenv('FERNET_KEY')
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def resolve_user_identifier(external_user_id: str = None, local_user_id: int = 0) -> tuple[str, bool]:
|
|
27
|
+
"""
|
|
28
|
+
Resuelve un identificador único de usuario desde external_user_id o local_user_id.
|
|
29
|
+
|
|
30
|
+
Lógica:
|
|
31
|
+
- Si external_user_id existe y no está vacío: usar external_user_id
|
|
32
|
+
- Si no, y local_user_id > 0: obtener email de la sesión actual y retornarlo como ID
|
|
33
|
+
- Si ninguno: retornar string vacío
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
if external_user_id and external_user_id.strip():
|
|
37
|
+
return external_user_id.strip(), False
|
|
38
|
+
elif local_user_id and local_user_id > 0:
|
|
39
|
+
# get the user information from the session
|
|
40
|
+
user_data = SessionManager.get('user')
|
|
41
|
+
if user_data:
|
|
42
|
+
return user_data.get('email', ''), True
|
|
43
|
+
|
|
44
|
+
return "", False
|
|
45
|
+
|
|
46
|
+
def render_prompt_from_template(self,
|
|
47
|
+
template_pathname: str,
|
|
48
|
+
query: str = None,
|
|
49
|
+
client_data: dict = {},
|
|
50
|
+
**kwargs) -> str:
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Normalizar la ruta para que funcione en cualquier SO
|
|
54
|
+
template_pathname = os.path.abspath(template_pathname)
|
|
55
|
+
template_dir = os.path.dirname(template_pathname)
|
|
56
|
+
template_file = os.path.basename(template_pathname)
|
|
57
|
+
|
|
58
|
+
env = Environment(loader=FileSystemLoader(template_dir))
|
|
59
|
+
template = env.get_template(template_file)
|
|
60
|
+
|
|
61
|
+
kwargs["query"] = query
|
|
62
|
+
|
|
63
|
+
# add all the keys in client_data to kwargs
|
|
64
|
+
kwargs.update(client_data)
|
|
65
|
+
|
|
66
|
+
# render my dynamic prompt
|
|
67
|
+
prompt = template.render(**kwargs)
|
|
68
|
+
return prompt
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logging.exception(e)
|
|
71
|
+
raise IAToolkitException(IAToolkitException.ErrorType.TEMPLATE_ERROR,
|
|
72
|
+
f'No se pudo renderizar el template: {template_pathname}, error: {str(e)}') from e
|
|
73
|
+
|
|
74
|
+
def render_prompt_from_string(self,
|
|
75
|
+
template_string: str,
|
|
76
|
+
searchpath: str | list[str] = None,
|
|
77
|
+
query: str = None,
|
|
78
|
+
client_data: dict = {},
|
|
79
|
+
**kwargs) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Renderiza un prompt a partir de un string de plantilla Jinja2.
|
|
82
|
+
|
|
83
|
+
:param template_string: El string que contiene la plantilla Jinja2.
|
|
84
|
+
:param searchpath: Una ruta o lista de rutas a directorios para buscar plantillas incluidas (con {% include %}).
|
|
85
|
+
:param query: El query principal a pasar a la plantilla.
|
|
86
|
+
:param client_data: Un diccionario con datos adicionales para la plantilla.
|
|
87
|
+
:param kwargs: Argumentos adicionales para la plantilla.
|
|
88
|
+
:return: El prompt renderizado como un string.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
# Si se proporciona un searchpath, se usa un FileSystemLoader para permitir includes.
|
|
92
|
+
if searchpath:
|
|
93
|
+
loader = FileSystemLoader(searchpath)
|
|
94
|
+
else:
|
|
95
|
+
loader = None # Sin loader, no se pueden incluir plantillas desde archivos.
|
|
96
|
+
|
|
97
|
+
env = Environment(loader=loader)
|
|
98
|
+
template = env.from_string(template_string)
|
|
99
|
+
|
|
100
|
+
kwargs["query"] = query
|
|
101
|
+
kwargs.update(client_data)
|
|
102
|
+
|
|
103
|
+
prompt = template.render(**kwargs)
|
|
104
|
+
return prompt
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logging.exception(e)
|
|
107
|
+
raise IAToolkitException(IAToolkitException.ErrorType.TEMPLATE_ERROR,
|
|
108
|
+
f'No se pudo renderizar el template desde el string, error: {str(e)}') from e
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def serialize(self, obj):
|
|
112
|
+
if isinstance(obj, datetime) or isinstance(obj, date):
|
|
113
|
+
return obj.isoformat()
|
|
114
|
+
elif isinstance(obj, Decimal):
|
|
115
|
+
return float(obj)
|
|
116
|
+
elif isinstance(obj, bytes):
|
|
117
|
+
return obj.decode('utf-8')
|
|
118
|
+
else:
|
|
119
|
+
raise TypeError(f"Type {type(obj)} not serializable")
|
|
120
|
+
|
|
121
|
+
def encrypt_key(self, key: str) -> str:
|
|
122
|
+
if not self.encryption_key:
|
|
123
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
124
|
+
'No se pudo obtener variable de ambiente para encriptar')
|
|
125
|
+
|
|
126
|
+
if not key:
|
|
127
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
128
|
+
'falta la clave a encriptar')
|
|
129
|
+
try:
|
|
130
|
+
cipher_suite = Fernet(self.encryption_key.encode('utf-8'))
|
|
131
|
+
|
|
132
|
+
encrypted_key = cipher_suite.encrypt(key.encode('utf-8'))
|
|
133
|
+
encrypted_key_str = base64.urlsafe_b64encode(encrypted_key).decode('utf-8')
|
|
134
|
+
|
|
135
|
+
return encrypted_key_str
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
138
|
+
f'No se pudo encriptar la clave: {str(e)}') from e
|
|
139
|
+
|
|
140
|
+
def decrypt_key(self, encrypted_key: str) -> str:
|
|
141
|
+
if not self.encryption_key:
|
|
142
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
143
|
+
'No se pudo obtener variable de ambiente para desencriptar')
|
|
144
|
+
if not encrypted_key:
|
|
145
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
146
|
+
'falta la clave a encriptar')
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
# transform to bytes first
|
|
150
|
+
encrypted_data_from_storage_bytes = base64.urlsafe_b64decode(encrypted_key.encode('utf-8'))
|
|
151
|
+
|
|
152
|
+
cipher_suite = Fernet(self.encryption_key.encode('utf-8'))
|
|
153
|
+
decrypted_key_bytes = cipher_suite.decrypt(encrypted_data_from_storage_bytes)
|
|
154
|
+
return decrypted_key_bytes.decode('utf-8')
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise IAToolkitException(IAToolkitException.ErrorType.CRYPT_ERROR,
|
|
157
|
+
f'No se pudo desencriptar la clave: {str(e)}') from e
|
|
158
|
+
|
|
159
|
+
def load_schema_from_yaml(self, file_path):
|
|
160
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
161
|
+
schema = yaml.safe_load(f)
|
|
162
|
+
return schema
|
|
163
|
+
|
|
164
|
+
def generate_context_for_schema(self, entity_name: str, schema_file: str = None, schema: dict = {}) -> str:
|
|
165
|
+
if not schema_file and not schema:
|
|
166
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR,
|
|
167
|
+
f'No se pudo obtener schema de la entidad: {entity_name}')
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if schema_file:
|
|
171
|
+
schema = self.load_schema_from_yaml(schema_file)
|
|
172
|
+
table_schema = self.generate_schema_table(schema)
|
|
173
|
+
return table_schema
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logging.exception(e)
|
|
176
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR,
|
|
177
|
+
f'No se pudo leer el schema de la entidad: {entity_name}') from e
|
|
178
|
+
|
|
179
|
+
def generate_schema_table(self, schema: dict) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Genera una descripción detallada y formateada en Markdown de un esquema.
|
|
182
|
+
Esta función está diseñada para manejar el formato específico de nuestros
|
|
183
|
+
archivos YAML, donde el esquema se define bajo una única clave raíz.
|
|
184
|
+
"""
|
|
185
|
+
if not schema or not isinstance(schema, dict):
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
# Asumimos que el YAML tiene una única clave raíz que nombra a la entidad.
|
|
189
|
+
if len(schema) == 1:
|
|
190
|
+
root_name = list(schema.keys())[0]
|
|
191
|
+
root_details = schema[root_name]
|
|
192
|
+
|
|
193
|
+
if isinstance(root_details, dict):
|
|
194
|
+
# Las claves de metadatos describen el objeto en sí, no sus propiedades hijas.
|
|
195
|
+
METADATA_KEYS = ['description', 'type', 'format', 'items', 'properties']
|
|
196
|
+
|
|
197
|
+
# Las propiedades son las claves restantes en el diccionario.
|
|
198
|
+
properties = {
|
|
199
|
+
k: v for k, v in root_details.items() if k not in METADATA_KEYS
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# La descripción del objeto raíz.
|
|
203
|
+
root_description = root_details.get('description', '')
|
|
204
|
+
|
|
205
|
+
# Formatea las propiedades extraídas usando la función auxiliar recursiva.
|
|
206
|
+
formatted_properties = self._format_json_schema(properties, 0)
|
|
207
|
+
|
|
208
|
+
# Construcción del resultado final, incluyendo el nombre del objeto raíz.
|
|
209
|
+
output_parts = [f"\n\n### Objeto: `{root_name}`"]
|
|
210
|
+
if root_description:
|
|
211
|
+
# Limpia la descripción para que se muestre bien
|
|
212
|
+
cleaned_description = '\n'.join(line.strip() for line in root_description.strip().split('\n'))
|
|
213
|
+
output_parts.append(f"{cleaned_description}")
|
|
214
|
+
|
|
215
|
+
if formatted_properties:
|
|
216
|
+
output_parts.append(f"**Campos del objeto `{root_name}`:**\n{formatted_properties}")
|
|
217
|
+
|
|
218
|
+
return "\n".join(output_parts)
|
|
219
|
+
|
|
220
|
+
# Si el esquema (como tender_schema.yaml) no tiene un objeto raíz,
|
|
221
|
+
# se formatea directamente como una lista de propiedades.
|
|
222
|
+
return self._format_json_schema(schema, 0)
|
|
223
|
+
|
|
224
|
+
def _format_json_schema(self, properties: dict, indent_level: int) -> str:
|
|
225
|
+
"""
|
|
226
|
+
Formatea de manera recursiva las propiedades de un esquema JSON/YAML.
|
|
227
|
+
"""
|
|
228
|
+
output = []
|
|
229
|
+
indent_str = ' ' * indent_level
|
|
230
|
+
|
|
231
|
+
for name, details in properties.items():
|
|
232
|
+
if not isinstance(details, dict):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
description = details.get('description', '')
|
|
236
|
+
data_type = details.get('type', 'any')
|
|
237
|
+
output.append(f"{indent_str}- **`{name.lower()}`** ({data_type}): {description}")
|
|
238
|
+
|
|
239
|
+
child_indent_str = ' ' * (indent_level + 1)
|
|
240
|
+
|
|
241
|
+
# Manejo de 'oneOf' para mostrar valores constantes
|
|
242
|
+
if 'oneOf' in details:
|
|
243
|
+
for item in details['oneOf']:
|
|
244
|
+
if 'const' in item:
|
|
245
|
+
const_desc = item.get('description', '')
|
|
246
|
+
output.append(f"{child_indent_str}- `{item['const']}`: {const_desc}")
|
|
247
|
+
|
|
248
|
+
# Manejo de 'items' para arrays
|
|
249
|
+
if 'items' in details:
|
|
250
|
+
items_details = details.get('items', {})
|
|
251
|
+
if isinstance(items_details, dict):
|
|
252
|
+
item_description = items_details.get('description')
|
|
253
|
+
if item_description:
|
|
254
|
+
# Limpiamos y añadimos la descripción del item
|
|
255
|
+
cleaned_description = '\n'.join(
|
|
256
|
+
f"{line.strip()}" for line in item_description.strip().split('\n')
|
|
257
|
+
)
|
|
258
|
+
output.append(
|
|
259
|
+
f"{child_indent_str}*Descripción de los elementos del array:*\n{child_indent_str}{cleaned_description}")
|
|
260
|
+
|
|
261
|
+
if 'properties' in items_details:
|
|
262
|
+
nested_properties = self._format_json_schema(items_details['properties'], indent_level + 1)
|
|
263
|
+
output.append(nested_properties)
|
|
264
|
+
|
|
265
|
+
# Manejo de 'properties' para objetos anidados estándar
|
|
266
|
+
if 'properties' in details:
|
|
267
|
+
nested_properties = self._format_json_schema(details['properties'], indent_level + 1)
|
|
268
|
+
output.append(nested_properties)
|
|
269
|
+
|
|
270
|
+
elif 'additionalProperties' in details and 'properties' in details.get('additionalProperties', {}):
|
|
271
|
+
# Imprime un marcador de posición para la clave dinámica.
|
|
272
|
+
output.append(
|
|
273
|
+
f"{child_indent_str}- **[*]** (object): Las claves de este objeto son dinámicas (ej. un ID).")
|
|
274
|
+
# Procesa las propiedades del objeto anidado.
|
|
275
|
+
nested_properties = self._format_json_schema(details['additionalProperties']['properties'],
|
|
276
|
+
indent_level + 2)
|
|
277
|
+
output.append(nested_properties)
|
|
278
|
+
|
|
279
|
+
return '\n'.join(output)
|
|
280
|
+
|
|
281
|
+
def load_markdown_context(self, filepath: str) -> str:
|
|
282
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
283
|
+
return f.read()
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def _get_verifier(self, rut: int):
|
|
287
|
+
value = 11 - sum([int(a) * int(b) for a, b in zip(str(rut).zfill(8), '32765432')]) % 11
|
|
288
|
+
return {10: 'K', 11: '0'}.get(value, str(value))
|
|
289
|
+
|
|
290
|
+
def validate_rut(self, rut_str):
|
|
291
|
+
if not rut_str or not isinstance(rut_str, str):
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
rut_str = rut_str.strip().replace('.', '').upper()
|
|
295
|
+
parts = rut_str.split('-')
|
|
296
|
+
if not len(parts) == 2:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
rut = int(parts[0])
|
|
301
|
+
except ValueError:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
if rut < 1000000:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
if not len(parts[1]) == 1:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
digit = parts[1].upper()
|
|
311
|
+
return digit == self._get_verifier(rut)
|
|
312
|
+
|
|
313
|
+
def get_files_by_extension(self, directory: str, extension: str, return_extension: bool = False) -> List[str]:
|
|
314
|
+
try:
|
|
315
|
+
# Normalizar la extensión (agregar punto si no lo tiene)
|
|
316
|
+
if not extension.startswith('.'):
|
|
317
|
+
extension = '.' + extension
|
|
318
|
+
|
|
319
|
+
# Verificar que el directorio existe
|
|
320
|
+
if not os.path.exists(directory):
|
|
321
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR,
|
|
322
|
+
f'El directorio no existe: {directory}')
|
|
323
|
+
|
|
324
|
+
if not os.path.isdir(directory):
|
|
325
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR,
|
|
326
|
+
f'La ruta no es un directorio: {directory}')
|
|
327
|
+
|
|
328
|
+
# Buscar archivos con la extensión especificada
|
|
329
|
+
files = []
|
|
330
|
+
for filename in os.listdir(directory):
|
|
331
|
+
file_path = os.path.join(directory, filename)
|
|
332
|
+
if os.path.isfile(file_path) and filename.endswith(extension):
|
|
333
|
+
if return_extension:
|
|
334
|
+
files.append(filename)
|
|
335
|
+
else:
|
|
336
|
+
name_without_extension = os.path.splitext(filename)[0]
|
|
337
|
+
files.append(name_without_extension)
|
|
338
|
+
|
|
339
|
+
return sorted(files) # Retornar lista ordenada alfabéticamente
|
|
340
|
+
|
|
341
|
+
except IAToolkitException:
|
|
342
|
+
raise
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logging.exception(e)
|
|
345
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR,
|
|
346
|
+
f'Error al buscar archivos en el directorio {directory}: {str(e)}') from e
|
|
347
|
+
|
|
348
|
+
def is_openai_model(self, model: str) -> bool:
|
|
349
|
+
openai_models = [
|
|
350
|
+
'gpt-5', 'gpt'
|
|
351
|
+
]
|
|
352
|
+
return any(openai_model in model.lower() for openai_model in openai_models)
|
|
353
|
+
|
|
354
|
+
def is_gemini_model(self, model: str) -> bool:
|
|
355
|
+
gemini_models = [
|
|
356
|
+
'gemini', 'gemini-2.5-pro'
|
|
357
|
+
]
|
|
358
|
+
return any(gemini_model in model.lower() for gemini_model in gemini_models)
|
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
#
|
|
4
4
|
# IAToolkit is open source software.
|
|
5
5
|
|
|
6
|
-
from flask import Flask, url_for
|
|
6
|
+
from flask import Flask, url_for
|
|
7
7
|
from flask_session import Session
|
|
8
8
|
from flask_injector import FlaskInjector
|
|
9
9
|
from flask_bcrypt import Bcrypt
|
|
10
10
|
from flask_cors import CORS
|
|
11
|
-
from common.auth import IAuthentication
|
|
12
|
-
from common.util import Utility
|
|
13
11
|
from common.exceptions import IAToolkitException
|
|
14
|
-
from common.session_manager import SessionManager
|
|
15
12
|
from urllib.parse import urlparse
|
|
16
13
|
import redis
|
|
17
14
|
import logging
|
|
@@ -294,6 +291,8 @@ class IAToolkit:
|
|
|
294
291
|
from infra.llm_proxy import LLMProxy
|
|
295
292
|
from infra.google_chat_app import GoogleChatApp
|
|
296
293
|
from infra.mail_app import MailApp
|
|
294
|
+
from common.auth import IAuthentication
|
|
295
|
+
from common.util import Utility
|
|
297
296
|
|
|
298
297
|
binder.bind(LLMProxy, to=LLMProxy, scope=singleton)
|
|
299
298
|
binder.bind(llmClient, to=llmClient, scope=singleton)
|
|
@@ -352,6 +351,7 @@ class IAToolkit:
|
|
|
352
351
|
# Configura context processors para templates
|
|
353
352
|
@self.app.context_processor
|
|
354
353
|
def inject_globals():
|
|
354
|
+
from common.session_manager import SessionManager
|
|
355
355
|
return {
|
|
356
356
|
'url_for': url_for,
|
|
357
357
|
'iatoolkit_version': self.version,
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
pyproject.toml
|
|
2
2
|
readme.md
|
|
3
3
|
requirements.txt
|
|
4
|
+
src/common/__init__.py
|
|
5
|
+
src/common/auth.py
|
|
6
|
+
src/common/exceptions.py
|
|
7
|
+
src/common/routes.py
|
|
8
|
+
src/common/session_manager.py
|
|
9
|
+
src/common/util.py
|
|
4
10
|
src/iatoolkit/__init__.py
|
|
5
11
|
src/iatoolkit/base_company.py
|
|
6
12
|
src/iatoolkit/cli_commands.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|