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,208 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
from iatoolkit.services.user_session_context_service import UserSessionContextService
|
|
11
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
12
|
+
from iatoolkit.services.llm_client_service import llmClient
|
|
13
|
+
from iatoolkit.repositories.models import Company
|
|
14
|
+
from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
|
|
15
|
+
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
16
|
+
from injector import inject
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HistoryManagerService:
|
|
20
|
+
"""
|
|
21
|
+
Manages conversation history for LLMs in a unified way.
|
|
22
|
+
Handles:
|
|
23
|
+
1. Server-side history (e.g., OpenAI response_ids).
|
|
24
|
+
2. Client-side history (e.g., Gemini message lists).
|
|
25
|
+
3. Database persistence retrieval (full chat history).
|
|
26
|
+
"""
|
|
27
|
+
TYPE_SERVER_SIDE = 'server_side' # For models like OpenAI
|
|
28
|
+
TYPE_CLIENT_SIDE = 'client_side' # For models like Gemini and Deepseek
|
|
29
|
+
|
|
30
|
+
GEMINI_MAX_TOKENS_CONTEXT_HISTORY = 200000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@inject
|
|
34
|
+
def __init__(self,
|
|
35
|
+
session_context: UserSessionContextService,
|
|
36
|
+
i18n: I18nService,
|
|
37
|
+
llm_query_repo: LLMQueryRepo,
|
|
38
|
+
profile_repo: ProfileRepo,
|
|
39
|
+
llm_client: Optional[llmClient] = None):
|
|
40
|
+
self.session_context = session_context
|
|
41
|
+
self.i18n = i18n
|
|
42
|
+
self.llm_query_repo = llm_query_repo
|
|
43
|
+
self.profile_repo = profile_repo
|
|
44
|
+
self.llm_client = llm_client
|
|
45
|
+
|
|
46
|
+
def initialize_context(self,
|
|
47
|
+
company_short_name: str,
|
|
48
|
+
user_identifier: str,
|
|
49
|
+
history_type: str,
|
|
50
|
+
prepared_context: str,
|
|
51
|
+
company: Company, model: str) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Initializes a new conversation history.
|
|
54
|
+
"""
|
|
55
|
+
# 1. Clear existing history
|
|
56
|
+
self.session_context.clear_llm_history(company_short_name, user_identifier, model=model)
|
|
57
|
+
|
|
58
|
+
if history_type == self.TYPE_SERVER_SIDE:
|
|
59
|
+
# OpenAI: Send system prompt to API and store the resulting ID
|
|
60
|
+
response_id = self.llm_client.set_company_context(
|
|
61
|
+
company=company,
|
|
62
|
+
company_base_context=prepared_context,
|
|
63
|
+
model=model
|
|
64
|
+
)
|
|
65
|
+
self.session_context.save_last_response_id(company_short_name, user_identifier, response_id, model=model)
|
|
66
|
+
self.session_context.save_initial_response_id(company_short_name, user_identifier, response_id, model=model)
|
|
67
|
+
return {'response_id': response_id}
|
|
68
|
+
|
|
69
|
+
elif history_type == self.TYPE_CLIENT_SIDE:
|
|
70
|
+
# Gemini: Store system prompt as the first message in the list
|
|
71
|
+
context_history = [{"role": "user", "content": prepared_context}]
|
|
72
|
+
self.session_context.save_context_history(company_short_name, user_identifier, context_history, model=model)
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
def populate_request_params(self,
|
|
78
|
+
handle: Any,
|
|
79
|
+
user_turn_prompt: str,
|
|
80
|
+
ignore_history: bool = False) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Populates the request_params within the HistoryHandle.
|
|
83
|
+
Returns True if a rebuild is needed, False otherwise.
|
|
84
|
+
"""
|
|
85
|
+
model = getattr(handle, "model", None)
|
|
86
|
+
|
|
87
|
+
if handle.type == self.TYPE_SERVER_SIDE:
|
|
88
|
+
if ignore_history:
|
|
89
|
+
previous_response_id = self.session_context.get_initial_response_id(
|
|
90
|
+
handle.company_short_name,handle.user_identifier,model=model)
|
|
91
|
+
else:
|
|
92
|
+
previous_response_id = self.session_context.get_last_response_id(
|
|
93
|
+
handle.company_short_name,handle.user_identifier,model=model)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if not previous_response_id:
|
|
97
|
+
handle.request_params = {}
|
|
98
|
+
return True # Needs rebuild
|
|
99
|
+
|
|
100
|
+
handle.request_params = {'previous_response_id': previous_response_id}
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
elif handle.type == self.TYPE_CLIENT_SIDE:
|
|
104
|
+
context_history = self.session_context.get_context_history(
|
|
105
|
+
handle.company_short_name,handle.user_identifier,model=model) or []
|
|
106
|
+
|
|
107
|
+
if not context_history:
|
|
108
|
+
handle.request_params = {}
|
|
109
|
+
return True # Needs rebuild
|
|
110
|
+
|
|
111
|
+
if ignore_history and len(context_history) > 1:
|
|
112
|
+
# Keep only system prompt
|
|
113
|
+
context_history = [context_history[0]]
|
|
114
|
+
|
|
115
|
+
# Append the current user turn to the context sent to the API
|
|
116
|
+
context_history.append({"role": "user", "content": user_turn_prompt})
|
|
117
|
+
|
|
118
|
+
self._trim_context_history(context_history)
|
|
119
|
+
|
|
120
|
+
handle.request_params = {'context_history': context_history}
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
handle.request_params = {}
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def update_history(self,
|
|
127
|
+
history_handle: Any,
|
|
128
|
+
user_turn_prompt: str,
|
|
129
|
+
response: Dict[str, Any]):
|
|
130
|
+
"""Saves or updates the history after a successful LLM call."""
|
|
131
|
+
|
|
132
|
+
# We access the type from the handle
|
|
133
|
+
history_type = history_handle.type
|
|
134
|
+
company_short_name = history_handle.company_short_name
|
|
135
|
+
user_identifier = history_handle.user_identifier
|
|
136
|
+
model = getattr(history_handle, "model", None)
|
|
137
|
+
|
|
138
|
+
if history_type == self.TYPE_SERVER_SIDE:
|
|
139
|
+
if "response_id" in response:
|
|
140
|
+
self.session_context.save_last_response_id(
|
|
141
|
+
company_short_name,
|
|
142
|
+
user_identifier,
|
|
143
|
+
response["response_id"],
|
|
144
|
+
model=model)
|
|
145
|
+
|
|
146
|
+
elif history_type == self.TYPE_CLIENT_SIDE:
|
|
147
|
+
# get the history for this company/user/model
|
|
148
|
+
context_history = self.session_context.get_context_history(
|
|
149
|
+
company_short_name,
|
|
150
|
+
user_identifier,
|
|
151
|
+
model=model)
|
|
152
|
+
|
|
153
|
+
# Ensure the user prompt is recorded if not already.
|
|
154
|
+
# We check content equality to handle the case where the previous message was
|
|
155
|
+
# also 'user' (e.g., System Prompt) but different content.
|
|
156
|
+
last_content = context_history[-1].get("content") if context_history else None
|
|
157
|
+
|
|
158
|
+
if last_content != user_turn_prompt:
|
|
159
|
+
context_history.append({"role": "user", "content": user_turn_prompt})
|
|
160
|
+
|
|
161
|
+
if response.get('answer'):
|
|
162
|
+
context_history.append({"role": "assistant", "content": response.get('answer', '')})
|
|
163
|
+
|
|
164
|
+
self.session_context.save_context_history(
|
|
165
|
+
company_short_name,
|
|
166
|
+
user_identifier,
|
|
167
|
+
context_history,
|
|
168
|
+
model=model)
|
|
169
|
+
|
|
170
|
+
def _trim_context_history(self, context_history: list):
|
|
171
|
+
"""Internal helper to keep token usage within limits for client-side history."""
|
|
172
|
+
if not context_history or len(context_history) <= 1:
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
total_tokens = sum(self.llm_client.count_tokens(json.dumps(message)) for message in context_history)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logging.error(f"Error counting tokens for history: {e}.")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
while total_tokens > self.GEMINI_MAX_TOKENS_CONTEXT_HISTORY and len(context_history) > 1:
|
|
181
|
+
try:
|
|
182
|
+
# Remove the oldest message after system prompt
|
|
183
|
+
removed_message = context_history.pop(1)
|
|
184
|
+
removed_tokens = self.llm_client.count_tokens(json.dumps(removed_message))
|
|
185
|
+
total_tokens -= removed_tokens
|
|
186
|
+
logging.warning(
|
|
187
|
+
f"History tokens exceed limit. Removed old message. New total: {total_tokens} tokens."
|
|
188
|
+
)
|
|
189
|
+
except IndexError:
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
# --- this is for the history popup in the chat page
|
|
193
|
+
def get_full_history(self, company_short_name: str, user_identifier: str) -> dict:
|
|
194
|
+
"""Retrieves the full persisted history from the database."""
|
|
195
|
+
try:
|
|
196
|
+
company = self.profile_repo.get_company_by_short_name(company_short_name)
|
|
197
|
+
if not company:
|
|
198
|
+
return {"error": self.i18n.t('errors.company_not_found', company_short_name=company_short_name)}
|
|
199
|
+
|
|
200
|
+
history = self.llm_query_repo.get_history(company, user_identifier)
|
|
201
|
+
if not history:
|
|
202
|
+
return {'message': 'empty history', 'history': []}
|
|
203
|
+
|
|
204
|
+
history_list = [query.to_dict() for query in history]
|
|
205
|
+
return {'message': 'history loaded ok', 'history': history_list}
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
return {'error': str(e)}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# iatoolkit/services/i18n_service.py
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
from injector import inject, singleton
|
|
5
|
+
from iatoolkit.common.util import Utility
|
|
6
|
+
from iatoolkit.services.language_service import LanguageService
|
|
7
|
+
|
|
8
|
+
@singleton
|
|
9
|
+
class I18nService:
|
|
10
|
+
"""
|
|
11
|
+
Servicio centralizado para manejar la internacionalización (i18n).
|
|
12
|
+
Carga todas las traducciones desde archivos YAML en memoria al iniciar.
|
|
13
|
+
"""
|
|
14
|
+
FALLBACK_LANGUAGE = 'es'
|
|
15
|
+
|
|
16
|
+
@inject
|
|
17
|
+
def __init__(self, util: Utility, language_service: LanguageService):
|
|
18
|
+
self.util = util
|
|
19
|
+
self.language_service = language_service
|
|
20
|
+
|
|
21
|
+
self.translations = {}
|
|
22
|
+
self._load_translations()
|
|
23
|
+
|
|
24
|
+
def _load_translations(self):
|
|
25
|
+
"""
|
|
26
|
+
Carga todos los archivos .yaml del directorio 'locales' en memoria.
|
|
27
|
+
"""
|
|
28
|
+
locales_dir = os.path.join(os.path.dirname(__file__), '..', 'locales')
|
|
29
|
+
if not os.path.exists(locales_dir):
|
|
30
|
+
logging.error("Directory 'locales' not found.")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
for filename in os.listdir(locales_dir):
|
|
34
|
+
if filename.endswith('.yaml'):
|
|
35
|
+
lang_code = filename.split('.')[0]
|
|
36
|
+
filepath = os.path.join(locales_dir, filename)
|
|
37
|
+
try:
|
|
38
|
+
self.translations[lang_code] = self.util.load_schema_from_yaml(filepath)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logging.error(f"Error while loading the translation file {filepath}: {e}")
|
|
41
|
+
|
|
42
|
+
def _get_nested_key(self, lang: str, key: str):
|
|
43
|
+
"""
|
|
44
|
+
Obtiene un valor de un diccionario anidado usando una clave con puntos.
|
|
45
|
+
"""
|
|
46
|
+
data = self.translations.get(lang, {})
|
|
47
|
+
keys = key.split('.')
|
|
48
|
+
for k in keys:
|
|
49
|
+
if isinstance(data, dict) and k in data:
|
|
50
|
+
data = data[k]
|
|
51
|
+
else:
|
|
52
|
+
return None
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
def get_translation_block(self, key: str, lang: str = None) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Gets a whole dictionary block from the translations.
|
|
58
|
+
Useful for passing a set of translations to JavaScript.
|
|
59
|
+
"""
|
|
60
|
+
if lang is None:
|
|
61
|
+
lang = self.language_service.get_current_language()
|
|
62
|
+
|
|
63
|
+
# 1. Try to get the block in the requested language
|
|
64
|
+
block = self._get_nested_key(lang, key)
|
|
65
|
+
|
|
66
|
+
# 2. If not found, try the fallback language
|
|
67
|
+
if not isinstance(block, dict):
|
|
68
|
+
block = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
|
|
69
|
+
|
|
70
|
+
return block if isinstance(block, dict) else {}
|
|
71
|
+
|
|
72
|
+
def t(self, key: str, lang: str = None, **kwargs) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Gets the translation for a given key.
|
|
75
|
+
If 'lang' is provided, it's used. Otherwise, it's determined automatically.
|
|
76
|
+
"""
|
|
77
|
+
# If no specific language is requested, determine it from the current context.
|
|
78
|
+
if lang is None:
|
|
79
|
+
lang = self.language_service.get_current_language()
|
|
80
|
+
|
|
81
|
+
# 1. Attempt to get the translation in the requested language
|
|
82
|
+
message = self._get_nested_key(lang, key)
|
|
83
|
+
|
|
84
|
+
# 2. If not found, try the fallback language
|
|
85
|
+
if message is None and lang != self.FALLBACK_LANGUAGE:
|
|
86
|
+
logging.warning(
|
|
87
|
+
f"Translation key '{key}' not found for language '{lang}'. Attempting fallback to '{self.FALLBACK_LANGUAGE}'.")
|
|
88
|
+
message = self._get_nested_key(self.FALLBACK_LANGUAGE, key)
|
|
89
|
+
|
|
90
|
+
# 3. If still not found, return the key itself as a last resort
|
|
91
|
+
if message is None:
|
|
92
|
+
logging.error(
|
|
93
|
+
f"Translation key '{key}' not found, even in fallback '{self.FALLBACK_LANGUAGE}'.")
|
|
94
|
+
return key
|
|
95
|
+
|
|
96
|
+
# 4. If variables are provided, format the message
|
|
97
|
+
if kwargs:
|
|
98
|
+
try:
|
|
99
|
+
return message.format(**kwargs)
|
|
100
|
+
except KeyError as e:
|
|
101
|
+
logging.error(f"Error formatting key '{key}': missing variable {e} in arguments.")
|
|
102
|
+
return message
|
|
103
|
+
|
|
104
|
+
return message
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
from injector import singleton, inject
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
from flask import Flask
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@singleton
|
|
15
|
+
class JWTService:
|
|
16
|
+
@inject
|
|
17
|
+
def __init__(self, app: Flask):
|
|
18
|
+
# Acceder a la configuración directamente desde app.config
|
|
19
|
+
try:
|
|
20
|
+
self.secret_key = app.config['IATOOLKIT_SECRET_KEY']
|
|
21
|
+
self.algorithm = app.config['JWT_ALGORITHM']
|
|
22
|
+
except KeyError as e:
|
|
23
|
+
logging.error(f"missing JWT configuration: {e}.")
|
|
24
|
+
raise RuntimeError(f"missing JWT configuration variables: {e}")
|
|
25
|
+
|
|
26
|
+
def generate_chat_jwt(self,
|
|
27
|
+
company_short_name: str,
|
|
28
|
+
user_identifier: str,
|
|
29
|
+
expires_delta_seconds: int) -> Optional[str]:
|
|
30
|
+
# generate a JWT for a chat session
|
|
31
|
+
try:
|
|
32
|
+
if not company_short_name or not user_identifier:
|
|
33
|
+
logging.error(f"Missing token ID: {company_short_name}/{user_identifier}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
'company_short_name': company_short_name,
|
|
38
|
+
'user_identifier': user_identifier,
|
|
39
|
+
'exp': time.time() + expires_delta_seconds,
|
|
40
|
+
'iat': time.time(),
|
|
41
|
+
'type': 'chat_session' # Identificador del tipo de token
|
|
42
|
+
}
|
|
43
|
+
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
|
44
|
+
return token
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logging.error(f"Error al generar JWT para {company_short_name}/{user_identifier}: {e}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def validate_chat_jwt(self, token: str) -> Optional[Dict[str, Any]]:
|
|
50
|
+
"""
|
|
51
|
+
Valida un JWT de sesión de chat.
|
|
52
|
+
Retorna el payload decodificado si es válido y coincide con la empresa, o None.
|
|
53
|
+
"""
|
|
54
|
+
if not token:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
|
58
|
+
|
|
59
|
+
# Validaciones adicionales
|
|
60
|
+
if payload.get('type') != 'chat_session':
|
|
61
|
+
logging.warning(f"Invalid JWT type '{payload.get('type')}'")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# user_identifier debe estar presente
|
|
65
|
+
if not payload.get('user_identifier'):
|
|
66
|
+
logging.warning(f"missing user_identifier in JWT payload.")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if not payload.get('company_short_name'):
|
|
70
|
+
logging.warning(f"missing company_short_name in JWT payload.")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
except jwt.InvalidTokenError as e:
|
|
76
|
+
logging.warning(f"Invalid JWT token:: {e}")
|
|
77
|
+
return None
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logging.error(f"unexpected error during JWT validation: {e}")
|
|
80
|
+
return None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# iatoolkit/services/language_service.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from injector import inject, singleton
|
|
5
|
+
from flask import g, request
|
|
6
|
+
from iatoolkit.repositories.profile_repo import ProfileRepo
|
|
7
|
+
from iatoolkit.services.configuration_service import ConfigurationService
|
|
8
|
+
from iatoolkit.common.session_manager import SessionManager
|
|
9
|
+
|
|
10
|
+
@singleton
|
|
11
|
+
class LanguageService:
|
|
12
|
+
"""
|
|
13
|
+
Determines the correct language for the current request
|
|
14
|
+
based on a defined priority order (session, URL, etc.)
|
|
15
|
+
and caches it in the Flask 'g' object for the request's lifecycle.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
FALLBACK_LANGUAGE = 'es'
|
|
19
|
+
|
|
20
|
+
@inject
|
|
21
|
+
def __init__(self,
|
|
22
|
+
config_service: ConfigurationService,
|
|
23
|
+
profile_repo: ProfileRepo):
|
|
24
|
+
self.config_service = config_service
|
|
25
|
+
self.profile_repo = profile_repo
|
|
26
|
+
|
|
27
|
+
def _get_company_short_name(self) -> str | None:
|
|
28
|
+
"""
|
|
29
|
+
Gets the company_short_name from the current request context.
|
|
30
|
+
This handles different scenarios like web sessions, public URLs, and API calls.
|
|
31
|
+
|
|
32
|
+
Priority Order:
|
|
33
|
+
1. Flask Session (for logged-in web users).
|
|
34
|
+
2. URL rule variable (for public pages and API endpoints).
|
|
35
|
+
"""
|
|
36
|
+
# 1. Check session for logged-in users
|
|
37
|
+
company_short_name = SessionManager.get('company_short_name')
|
|
38
|
+
if company_short_name:
|
|
39
|
+
return company_short_name
|
|
40
|
+
|
|
41
|
+
# 2. Check URL arguments (e.g., /<company_short_name>/login)
|
|
42
|
+
# This covers public pages and most API calls.
|
|
43
|
+
if request.view_args and 'company_short_name' in request.view_args:
|
|
44
|
+
return request.view_args['company_short_name']
|
|
45
|
+
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def get_current_language(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Determines and caches the language for the current request using a priority order:
|
|
51
|
+
0. Query parameter '?lang=<code>' (highest priority; e.g., 'en', 'es').
|
|
52
|
+
1. User's preference (from their profile).
|
|
53
|
+
2. Company's default language.
|
|
54
|
+
3. System-wide fallback language ('es').
|
|
55
|
+
"""
|
|
56
|
+
if 'lang' in g:
|
|
57
|
+
return g.lang
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Priority 0: Explicit query parameter (?lang=)
|
|
61
|
+
lang_arg = request.args.get('lang')
|
|
62
|
+
if lang_arg:
|
|
63
|
+
g.lang = lang_arg
|
|
64
|
+
return g.lang
|
|
65
|
+
|
|
66
|
+
# Priority 1: User's preferred language
|
|
67
|
+
user_identifier = SessionManager.get('user_identifier')
|
|
68
|
+
if user_identifier:
|
|
69
|
+
user = self.profile_repo.get_user_by_email(user_identifier)
|
|
70
|
+
if user and user.preferred_language:
|
|
71
|
+
logging.debug(f"Language determined by user preference: {user.preferred_language}")
|
|
72
|
+
g.lang = user.preferred_language
|
|
73
|
+
return g.lang
|
|
74
|
+
|
|
75
|
+
# Priority 2: Company's default language
|
|
76
|
+
company_short_name = self._get_company_short_name()
|
|
77
|
+
if company_short_name:
|
|
78
|
+
locale = self.config_service.get_configuration(company_short_name, 'locale')
|
|
79
|
+
if locale:
|
|
80
|
+
company_language = locale.split('_')[0]
|
|
81
|
+
g.lang = company_language
|
|
82
|
+
return g.lang
|
|
83
|
+
except Exception as e:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# Priority 3: System-wide fallback
|
|
87
|
+
logging.debug(f"Language determined by system fallback: {self.FALLBACK_LANGUAGE}")
|
|
88
|
+
g.lang = self.FALLBACK_LANGUAGE
|
|
89
|
+
return g.lang
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from iatoolkit.common.exceptions import IAToolkitException
|
|
11
|
+
from injector import inject, singleton
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@singleton
|
|
15
|
+
class LicenseService:
|
|
16
|
+
"""
|
|
17
|
+
Manages system restrictions and features based on a license (JWT).
|
|
18
|
+
If no license or an invalid license is provided, Community Edition limits apply.
|
|
19
|
+
"""
|
|
20
|
+
@inject
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.limits = self._load_limits()
|
|
23
|
+
|
|
24
|
+
def _load_limits(self):
|
|
25
|
+
# 1. Define default limits (Community Edition)
|
|
26
|
+
default_limits = {
|
|
27
|
+
"license_type": "Community Edition",
|
|
28
|
+
"plan": "Open Source (Community Edition)",
|
|
29
|
+
"max_companies": 1,
|
|
30
|
+
"max_tools": 3,
|
|
31
|
+
"features": {
|
|
32
|
+
"multi_tenant": False,
|
|
33
|
+
"rag_advanced": False,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return default_limits
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- Information Getters ---
|
|
40
|
+
def get_license_type(self) -> str:
|
|
41
|
+
return self.limits.get("license_type", "Community Edition")
|
|
42
|
+
|
|
43
|
+
def get_plan_name(self) -> str:
|
|
44
|
+
return self.limits.get("plan", "Unknown")
|
|
45
|
+
|
|
46
|
+
def get_max_companies(self) -> int:
|
|
47
|
+
return self.limits.get("max_companies", 1)
|
|
48
|
+
|
|
49
|
+
def get_max_tools_per_company(self) -> int:
|
|
50
|
+
return self.limits.get("max_tools", 3)
|
|
51
|
+
|
|
52
|
+
def get_license_info(self) -> str:
|
|
53
|
+
return f"Plan: {self.get_plan_name()}, Companies: {self.get_max_companies()}, Tools: {self.get_max_tools_per_company()}"
|
|
54
|
+
|
|
55
|
+
# --- Restriction Validators ---
|
|
56
|
+
|
|
57
|
+
def validate_company_limit(self, current_count: int):
|
|
58
|
+
"""Raises exception if the limit of active companies is exceeded."""
|
|
59
|
+
limit = self.get_max_companies()
|
|
60
|
+
# -1 means unlimited
|
|
61
|
+
if limit != -1 and current_count > limit:
|
|
62
|
+
raise IAToolkitException(
|
|
63
|
+
IAToolkitException.ErrorType.PERMISSION,
|
|
64
|
+
f"Company limit ({limit}) reached for plan '{self.get_plan_name()}'."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_tool_config_limit(self, tools_config: list):
|
|
69
|
+
"""Validates a configuration list before processing it."""
|
|
70
|
+
limit = self.get_max_tools_per_company()
|
|
71
|
+
if limit != -1 and len(tools_config) > limit:
|
|
72
|
+
raise IAToolkitException(
|
|
73
|
+
IAToolkitException.ErrorType.PERMISSION,
|
|
74
|
+
f"Configuration defines {len(tools_config)} tools, but limit is {limit}."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# --- Feature Gating Validators ---
|
|
78
|
+
|
|
79
|
+
def has_feature(self, feature_key: str) -> bool:
|
|
80
|
+
"""Checks if a specific feature is enabled in the license."""
|
|
81
|
+
features = self.limits.get("features", {})
|
|
82
|
+
return features.get(feature_key, False)
|