iatoolkit 0.91.1__py3-none-any.whl → 1.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. iatoolkit/__init__.py +6 -4
  2. iatoolkit/base_company.py +0 -16
  3. iatoolkit/cli_commands.py +3 -14
  4. iatoolkit/common/exceptions.py +1 -0
  5. iatoolkit/common/interfaces/__init__.py +0 -0
  6. iatoolkit/common/interfaces/asset_storage.py +34 -0
  7. iatoolkit/common/interfaces/database_provider.py +38 -0
  8. iatoolkit/common/model_registry.py +159 -0
  9. iatoolkit/common/routes.py +42 -5
  10. iatoolkit/common/util.py +11 -12
  11. iatoolkit/company_registry.py +5 -0
  12. iatoolkit/core.py +51 -20
  13. iatoolkit/infra/llm_providers/__init__.py +0 -0
  14. iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
  15. iatoolkit/infra/{gemini_adapter.py → llm_providers/gemini_adapter.py} +11 -17
  16. iatoolkit/infra/{openai_adapter.py → llm_providers/openai_adapter.py} +41 -7
  17. iatoolkit/infra/llm_proxy.py +235 -134
  18. iatoolkit/infra/llm_response.py +5 -0
  19. iatoolkit/locales/en.yaml +124 -2
  20. iatoolkit/locales/es.yaml +122 -0
  21. iatoolkit/repositories/database_manager.py +44 -19
  22. iatoolkit/repositories/document_repo.py +7 -0
  23. iatoolkit/repositories/filesystem_asset_repository.py +36 -0
  24. iatoolkit/repositories/llm_query_repo.py +2 -0
  25. iatoolkit/repositories/models.py +72 -79
  26. iatoolkit/repositories/profile_repo.py +59 -3
  27. iatoolkit/repositories/vs_repo.py +22 -24
  28. iatoolkit/services/company_context_service.py +88 -39
  29. iatoolkit/services/configuration_service.py +157 -68
  30. iatoolkit/services/dispatcher_service.py +21 -3
  31. iatoolkit/services/file_processor_service.py +0 -5
  32. iatoolkit/services/history_manager_service.py +43 -24
  33. iatoolkit/services/knowledge_base_service.py +412 -0
  34. iatoolkit/{infra/llm_client.py → services/llm_client_service.py} +38 -29
  35. iatoolkit/services/load_documents_service.py +18 -47
  36. iatoolkit/services/profile_service.py +32 -4
  37. iatoolkit/services/prompt_service.py +32 -30
  38. iatoolkit/services/query_service.py +51 -26
  39. iatoolkit/services/sql_service.py +105 -74
  40. iatoolkit/services/tool_service.py +26 -11
  41. iatoolkit/services/user_session_context_service.py +115 -63
  42. iatoolkit/static/js/chat_main.js +44 -4
  43. iatoolkit/static/js/chat_model_selector.js +227 -0
  44. iatoolkit/static/js/chat_onboarding_button.js +1 -1
  45. iatoolkit/static/js/chat_reload_button.js +4 -1
  46. iatoolkit/static/styles/chat_iatoolkit.css +58 -2
  47. iatoolkit/static/styles/llm_output.css +34 -1
  48. iatoolkit/system_prompts/query_main.prompt +26 -2
  49. iatoolkit/templates/base.html +13 -0
  50. iatoolkit/templates/chat.html +44 -2
  51. iatoolkit/templates/onboarding_shell.html +0 -1
  52. iatoolkit/views/base_login_view.py +7 -2
  53. iatoolkit/views/chat_view.py +76 -0
  54. iatoolkit/views/load_company_configuration_api_view.py +49 -0
  55. iatoolkit/views/load_document_api_view.py +14 -10
  56. iatoolkit/views/login_view.py +8 -3
  57. iatoolkit/views/rag_api_view.py +216 -0
  58. iatoolkit/views/users_api_view.py +33 -0
  59. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/METADATA +4 -4
  60. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/RECORD +64 -56
  61. iatoolkit/repositories/tasks_repo.py +0 -52
  62. iatoolkit/services/search_service.py +0 -55
  63. iatoolkit/services/tasks_service.py +0 -188
  64. iatoolkit/views/tasks_api_view.py +0 -72
  65. iatoolkit/views/tasks_review_api_view.py +0 -55
  66. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/WHEEL +0 -0
  67. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/licenses/LICENSE +0 -0
  68. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
  69. {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/top_level.txt +0 -0
@@ -3,13 +3,13 @@
3
3
  #
4
4
  # IAToolkit is open source software.
5
5
 
6
+ from iatoolkit.common.interfaces.database_provider import DatabaseProvider
6
7
  from iatoolkit.repositories.database_manager import DatabaseManager
7
- from iatoolkit.common.util import Utility
8
8
  from iatoolkit.services.i18n_service import I18nService
9
9
  from iatoolkit.common.exceptions import IAToolkitException
10
- from sqlalchemy import text
11
- from sqlalchemy.exc import SQLAlchemyError
10
+ from iatoolkit.common.util import Utility
12
11
  from injector import inject, singleton
12
+ from typing import Callable
13
13
  import json
14
14
  import logging
15
15
 
@@ -28,91 +28,124 @@ class SqlService:
28
28
  self.util = util
29
29
  self.i18n_service = i18n_service
30
30
 
31
- # Cache for database connections
32
- self._db_connections: dict[str, DatabaseManager] = {}
31
+ # Cache for database providers. Key is tuple: (company_short_name, db_name)
32
+ # Value is the abstract interface DatabaseProvider
33
+ self._db_connections: dict[tuple[str, str], DatabaseProvider] = {}
34
+
35
+ # cache for database schemas. Key is tuple: (company_short_name, db_name)
36
+ self._db_schemas: dict[tuple[str, str], str] = {}
37
+
38
+ # Registry of factory functions.
39
+ # Format: {'connection_type': function(config_dict) -> DatabaseProvider}
40
+ self._provider_factories: dict[str, Callable[[dict], DatabaseProvider]] = {}
41
+
42
+ # Register the default 'direct' strategy (SQLAlchemy)
43
+ self.register_provider_factory('direct', self._create_direct_connection)
44
+
45
+ def register_provider_factory(self, connection_type: str, factory: Callable[[dict], DatabaseProvider]):
46
+ """
47
+ Allows plugins (Enterprise) to register new connection types.
48
+ """
49
+ self._provider_factories[connection_type] = factory
50
+
51
+ def _create_direct_connection(self, config: dict) -> DatabaseProvider:
52
+ """Default factory for standard SQLAlchemy connections."""
53
+ uri = config.get('db_uri') or config.get('DATABASE_URI')
54
+ schema = config.get('schema')
55
+ if not uri:
56
+ raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR,
57
+ "Missing db_uri for direct connection")
58
+ return DatabaseManager(uri, schema=schema, register_pgvector=False)
33
59
 
34
- def register_database(self, db_uri: str, db_name: str, schema: str | None = None):
60
+ def register_database(self, company_short_name: str, db_name: str, config: dict):
35
61
  """
36
- Creates and caches a DatabaseManager instance for a given database name and URI.
37
- If a database with the same name is already registered, it does nothing.
62
+ Creates and caches a DatabaseProvider instance based on the configuration.
38
63
  """
39
- if db_name in self._db_connections:
64
+ key = (company_short_name, db_name)
65
+
66
+ # Determine connection type (default to 'direct')
67
+ conn_type = config.get('connection_type', 'direct')
68
+ logging.info(f"Registering DB '{db_name}' ({conn_type}) for company '{company_short_name}'")
69
+
70
+ factory = self._provider_factories.get(conn_type)
71
+ if not factory:
72
+ logging.error(f"Unknown connection type '{conn_type}' for DB '{db_name}'. Skipping.")
40
73
  return
41
74
 
42
- logging.info(f"Registering and creating connection for database: '{db_name}' (schema: {schema})")
75
+ try:
76
+ # Create the provider using the appropriate factory
77
+ provider_instance = factory(config)
78
+ self._db_connections[key] = provider_instance
79
+
80
+ # save the db_schema
81
+ self._db_schemas[key] = config.get('schema', 'public')
82
+ except Exception as e:
83
+ logging.error(f"Failed to register DB '{db_name}': {e}")
84
+ # We don't raise here to allow other DBs to load if one fails
43
85
 
44
- # create the database connection and save it on the cache
45
- db_manager = DatabaseManager(db_uri, schema=schema, register_pgvector=False)
46
- self._db_connections[db_name] = db_manager
86
+ def get_db_names(self, company_short_name: str) -> list[str]:
87
+ """
88
+ Returns list of logical database names available ONLY for the specified company.
89
+ """
90
+ return [db for (co, db) in self._db_connections.keys() if co == company_short_name]
47
91
 
48
- def get_database_manager(self, db_name: str) -> DatabaseManager:
92
+ def get_database_provider(self, company_short_name: str, db_name: str) -> DatabaseProvider:
49
93
  """
50
- Retrieves a registered DatabaseManager instance from the cache.
94
+ Retrieves a registered DatabaseProvider instance using the composite key.
95
+ Replaces the old 'get_database_manager'.
51
96
  """
97
+ key = (company_short_name, db_name)
52
98
  try:
53
- return self._db_connections[db_name]
99
+ return self._db_connections[key]
54
100
  except KeyError:
55
- logging.error(f"Attempted to access unregistered database: '{db_name}'")
101
+ logging.error(
102
+ f"Attempted to access unregistered database: '{db_name}' for company '{company_short_name}'"
103
+ )
56
104
  raise IAToolkitException(
57
105
  IAToolkitException.ErrorType.DATABASE_ERROR,
58
- f"Database '{db_name}' is not registered with the SqlService."
106
+ f"Database '{db_name}' is not registered for this company."
59
107
  )
60
108
 
61
- def exec_sql(self, company_short_name: str,
62
- database: str,
63
- query: str,
64
- format: str = 'json',
65
- commit: bool = False):
109
+ def exec_sql(self, company_short_name: str, **kwargs):
66
110
  """
67
- Executes a raw SQL statement against a registered database.
68
-
69
- Args:
70
- company_short_name: The company identifier (for logging/context).
71
- database: The logical name of the database to query.
72
- query: The SQL statement to execute.
73
- format: The output format ('json' or 'dict'). Only relevant for SELECT queries.
74
- commit: Whether to commit the transaction immediately after execution.
75
- Use True for INSERT/UPDATE/DELETE statements.
76
-
77
- Returns:
78
- - A JSON string or list of dicts for SELECT queries.
79
- - A dictionary {'rowcount': N} for non-returning statements (INSERT/UPDATE) if not using RETURNING.
111
+ Executes a raw SQL statement against a registered database provider.
112
+ Delegates the actual execution details to the provider implementation.
80
113
  """
81
- try:
82
- # 1. Get the database manager from the cache
83
- db_manager = self.get_database_manager(database)
84
- session = db_manager.get_session()
85
-
86
- # 2. Execute the SQL statement
87
- result = session.execute(text(query))
114
+ database_name = kwargs.get('database_key')
115
+ query = kwargs.get('query')
116
+ format = kwargs.get('format', 'json')
117
+ commit = kwargs.get('commit')
88
118
 
89
- # 3. Handle Commit
90
- if commit:
91
- session.commit()
119
+ if not database_name:
120
+ raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR,
121
+ 'missing database_name in call to exec_sql')
92
122
 
93
- # 4. Process Results
94
- # Check if the query returns rows (e.g., SELECT or INSERT ... RETURNING)
95
- if result.returns_rows:
96
- cols = result.keys()
97
- rows_context = [dict(zip(cols, row)) for row in result.fetchall()]
123
+ try:
124
+ # 1. Get the abstract provider (could be Direct or Bridge)
125
+ provider = self.get_database_provider(company_short_name, database_name)
126
+ db_schema = self._db_schemas[(company_short_name, database_name)]
98
127
 
99
- if format == 'dict':
100
- return rows_context
128
+ # 2. Delegate execution
129
+ # The provider returns a clean List[Dict] or Dict result
130
+ result_data = provider.execute_query(query=query, commit=commit)
101
131
 
102
- # serialize the result
103
- return json.dumps(rows_context, default=self.util.serialize)
132
+ # 3. Handle Formatting (Service layer responsibility)
133
+ if format == 'dict':
134
+ return result_data
104
135
 
105
- # For statements that don't return rows (standard UPDATE/DELETE)
106
- return {'rowcount': result.rowcount}
136
+ # Serialize the result
137
+ return json.dumps(result_data, default=self.util.serialize)
107
138
 
108
139
  except IAToolkitException:
109
- # Re-raise exceptions from get_database_manager to preserve the specific error
110
140
  raise
111
141
  except Exception as e:
112
- # Attempt to rollback if a session was active
113
- db_manager = self._db_connections.get(database)
114
- if db_manager:
115
- db_manager.get_session().rollback()
142
+ # Attempt rollback if supported/needed
143
+ try:
144
+ provider = self.get_database_provider(company_short_name, database_name)
145
+ if provider:
146
+ provider.rollback()
147
+ except Exception:
148
+ pass
116
149
 
117
150
  error_message = str(e)
118
151
  if 'timed out' in str(e):
@@ -122,22 +155,20 @@ class SqlService:
122
155
  raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR,
123
156
  error_message) from e
124
157
 
125
- def commit(self, database: str):
158
+ def commit(self, company_short_name: str, database_name: str):
126
159
  """
127
- Commits the current transaction for a registered database.
128
- Useful when multiple exec_sql calls are part of a single transaction.
160
+ Commits the current transaction for a registered database provider.
129
161
  """
130
-
131
- # Get the database manager from the cache
132
- db_manager = self.get_database_manager(database)
162
+ provider = self.get_database_provider(company_short_name, database_name)
133
163
  try:
134
- db_manager.get_session().commit()
135
- except SQLAlchemyError as db_error:
136
- db_manager.get_session().rollback()
137
- logging.error(f"Error de base de datos: {str(db_error)}")
138
- raise db_error
164
+ provider.commit()
139
165
  except Exception as e:
140
- logging.error(f"error while commiting sql: '{str(e)}'")
166
+ # Try rollback
167
+ try:
168
+ provider.rollback()
169
+ except:
170
+ pass
171
+ logging.error(f"Error while committing sql: '{str(e)}'")
141
172
  raise IAToolkitException(
142
173
  IAToolkitException.ErrorType.DATABASE_ERROR, str(e)
143
174
  )
@@ -5,6 +5,7 @@
5
5
 
6
6
  from injector import inject
7
7
  from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
8
+ from iatoolkit.repositories.profile_repo import ProfileRepo
8
9
  from iatoolkit.repositories.models import Company, Tool
9
10
  from iatoolkit.common.exceptions import IAToolkitException
10
11
  from iatoolkit.services.sql_service import SqlService
@@ -98,20 +99,20 @@ _SYSTEM_TOOLS = [
98
99
  },
99
100
  {
100
101
  "function_name": "iat_sql_query",
101
- "description": "Servicio SQL de IAToolkit: debes utilizar este servicio para todas las consultas a base de datos.",
102
+ "description": "Servicio SQL de IAToolkit: debes utilizar este servicio para todas las consultas SQL a bases de datos.",
102
103
  "parameters": {
103
104
  "type": "object",
104
105
  "properties": {
105
- "database": {
106
+ "database_key": {
106
107
  "type": "string",
107
- "description": "nombre de la base de datos a consultar: `database_name`"
108
+ "description": "IMPORTANT: nombre de la base de datos a consultar."
108
109
  },
109
110
  "query": {
110
111
  "type": "string",
111
112
  "description": "string con la consulta en sql"
112
113
  },
113
114
  },
114
- "required": ["database", "query"]
115
+ "required": ["database_key", "query"]
115
116
  }
116
117
  }
117
118
  ]
@@ -121,10 +122,12 @@ class ToolService:
121
122
  @inject
122
123
  def __init__(self,
123
124
  llm_query_repo: LLMQueryRepo,
125
+ profile_repo: ProfileRepo,
124
126
  sql_service: SqlService,
125
127
  excel_service: ExcelService,
126
128
  mail_service: MailService):
127
129
  self.llm_query_repo = llm_query_repo
130
+ self.profile_repo = profile_repo
128
131
  self.sql_service = sql_service
129
132
  self.excel_service = excel_service
130
133
  self.mail_service = mail_service
@@ -158,14 +161,22 @@ class ToolService:
158
161
  self.llm_query_repo.rollback()
159
162
  raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR, str(e))
160
163
 
161
- def sync_company_tools(self, company_instance, tools_config: list):
164
+ def sync_company_tools(self, company_short_name: str, tools_config: list):
162
165
  """
163
166
  Synchronizes tools from YAML config to Database (Create/Update/Delete strategy).
164
167
  """
168
+ if not tools_config:
169
+ return
170
+
171
+ company = self.profile_repo.get_company_by_short_name(company_short_name)
172
+ if not company:
173
+ raise IAToolkitException(IAToolkitException.ErrorType.INVALID_NAME,
174
+ f'Company {company_short_name} not found')
175
+
165
176
  try:
166
177
  # 1. Get existing tools map for later cleanup
167
178
  existing_tools = {
168
- f.name: f for f in self.llm_query_repo.get_company_tools(company_instance.company)
179
+ f.name: f for f in self.llm_query_repo.get_company_tools(company)
169
180
  }
170
181
  defined_tool_names = set()
171
182
 
@@ -177,7 +188,7 @@ class ToolService:
177
188
  # Construct the tool object with current config values
178
189
  # We create a new transient object and let the repo merge it
179
190
  tool_obj = Tool(
180
- company_id=company_instance.company.id,
191
+ company_id=company.id,
181
192
  name=name,
182
193
  description=tool_data['description'],
183
194
  parameters=tool_data['params'],
@@ -206,11 +217,12 @@ class ToolService:
206
217
  Returns the list of tools (System + Company) formatted for the LLM (OpenAI Schema).
207
218
  """
208
219
  tools = []
209
- # Obtiene tanto las de la empresa como las del sistema (la query del repo debería soportar esto con OR)
210
- functions = self.llm_query_repo.get_company_tools(company)
211
220
 
212
- for function in functions:
213
- # Clonamos para no modificar el objeto de la sesión SQLAlchemy
221
+ # get all the tools for the company and system
222
+ company_tools = self.llm_query_repo.get_company_tools(company)
223
+
224
+ for function in company_tools:
225
+ # clone for no modify the SQLAlchemy session object
214
226
  params = function.parameters.copy() if function.parameters else {}
215
227
  params["additionalProperties"] = False
216
228
 
@@ -221,6 +233,9 @@ class ToolService:
221
233
  "parameters": params,
222
234
  "strict": True
223
235
  }
236
+ if function.name == 'iat_sql_query':
237
+ params['properties']['database_key']['enum'] = self.sql_service.get_db_names(company.short_name)
238
+
224
239
  tools.append(ai_tool)
225
240
  return tools
226
241
 
@@ -15,73 +15,101 @@ class UserSessionContextService:
15
15
  Esto mejora la atomicidad y la eficiencia.
16
16
  """
17
17
 
18
- def _get_session_key(self, company_short_name: str, user_identifier: str) -> Optional[str]:
18
+ def _get_session_key(self, company_short_name: str, user_identifier: str, model: str = None) -> Optional[str]:
19
19
  """Devuelve la clave única de Redis para el Hash de sesión del usuario."""
20
20
  user_identifier = (user_identifier or "").strip()
21
21
  if not company_short_name or not user_identifier:
22
22
  return None
23
- return f"session:{company_short_name}/{user_identifier}"
24
23
 
25
- def clear_all_context(self, company_short_name: str, user_identifier: str):
26
- """Limpia el contexto del LLM en la sesión para un usuario de forma atómica."""
27
- session_key = self._get_session_key(company_short_name, user_identifier)
24
+ model_key = "" if not model else f"-{model}"
25
+ return f"session:{company_short_name}/{user_identifier}{model_key}"
26
+
27
+ def clear_all_context(self, company_short_name: str, user_identifier: str, model: str = None):
28
+ """Clears LLM-related context for a user (history and response IDs), preserving profile_data."""
29
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
28
30
  if session_key:
29
- # RedisSessionManager.remove(session_key)
30
31
  # 'profile_data' should not be deleted
31
- RedisSessionManager.hdel(session_key, 'context_version')
32
- RedisSessionManager.hdel(session_key, 'context_history')
33
- RedisSessionManager.hdel(session_key, 'last_response_id')
32
+ RedisSessionManager.hdel(session_key, "context_version")
33
+ RedisSessionManager.hdel(session_key, "context_history")
34
+ RedisSessionManager.hdel(session_key, "last_response_id")
34
35
 
35
- def clear_llm_history(self, company_short_name: str, user_identifier: str):
36
- """Limpia solo los campos relacionados con el historial del LLM (ID y chat)."""
37
- session_key = self._get_session_key(company_short_name, user_identifier)
36
+ def clear_llm_history(self, company_short_name: str, user_identifier: str, model: str = None):
37
+ """Clears only LLM history fields (last_response_id and context_history)."""
38
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
38
39
  if session_key:
39
- RedisSessionManager.hdel(session_key, 'last_response_id', 'context_history')
40
+ RedisSessionManager.hdel(session_key, "last_response_id", "context_history")
40
41
 
41
- def get_last_response_id(self, company_short_name: str, user_identifier: str) -> Optional[str]:
42
- session_key = self._get_session_key(company_short_name, user_identifier)
42
+ def get_last_response_id(self, company_short_name: str, user_identifier: str, model: str = None) -> Optional[str]:
43
+ """Returns the last LLM response ID for this user/model combination."""
44
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
43
45
  if not session_key:
44
46
  return None
45
- return RedisSessionManager.hget(session_key, 'last_response_id')
46
-
47
- def save_last_response_id(self, company_short_name: str, user_identifier: str, response_id: str):
48
- session_key = self._get_session_key(company_short_name, user_identifier)
47
+ return RedisSessionManager.hget(session_key, "last_response_id")
48
+
49
+ def save_last_response_id(self,
50
+ company_short_name: str,
51
+ user_identifier: str,
52
+ response_id: str,
53
+ model: str = None,
54
+ ):
55
+ """Persists the last LLM response ID for this user/model combination."""
56
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
49
57
  if session_key:
50
- RedisSessionManager.hset(session_key, 'last_response_id', response_id)
58
+ RedisSessionManager.hset(session_key, "last_response_id", response_id)
51
59
 
52
- def get_initial_response_id(self, company_short_name: str, user_identifier: str) -> Optional[str]:
60
+ def get_initial_response_id(self,
61
+ company_short_name: str,
62
+ user_identifier: str,
63
+ model: str = None,
64
+ ) -> Optional[str]:
53
65
  """
54
- Obtiene el ID de respuesta inicial desde la sesión del usuario.
55
- Este ID corresponde al estado del LLM justo después de haber configurado el contexto.
66
+ Returns the initial LLM response ID for this user/model combination.
67
+ This ID represents the state right after the context was set on the LLM.
56
68
  """
57
- session_key = self._get_session_key(company_short_name, user_identifier)
69
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
58
70
  if not session_key:
59
71
  return None
60
- return RedisSessionManager.hget(session_key, 'initial_response_id')
61
-
62
- def save_initial_response_id(self, company_short_name: str, user_identifier: str, response_id: str):
63
- """
64
- Guarda el ID de respuesta inicial en la sesión del usuario.
65
- """
66
- session_key = self._get_session_key(company_short_name, user_identifier)
72
+ return RedisSessionManager.hget(session_key, "initial_response_id")
73
+
74
+ def save_initial_response_id(self,
75
+ company_short_name: str,
76
+ user_identifier: str,
77
+ response_id: str,
78
+ model: str = None,
79
+ ):
80
+ """Persists the initial LLM response ID for this user/model combination."""
81
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
67
82
  if session_key:
68
- RedisSessionManager.hset(session_key, 'initial_response_id', response_id)
69
-
70
- def save_context_history(self, company_short_name: str, user_identifier: str, context_history: List[Dict]):
71
- session_key = self._get_session_key(company_short_name, user_identifier)
83
+ RedisSessionManager.hset(session_key, "initial_response_id", response_id)
84
+
85
+ def save_context_history(
86
+ self,
87
+ company_short_name: str,
88
+ user_identifier: str,
89
+ context_history: List[Dict],
90
+ model: str = None,
91
+ ):
92
+ """Serializes and stores the context history for this user/model combination."""
93
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
72
94
  if session_key:
73
95
  try:
74
96
  history_json = json.dumps(context_history)
75
- RedisSessionManager.hset(session_key, 'context_history', history_json)
97
+ RedisSessionManager.hset(session_key, "context_history", history_json)
76
98
  except (TypeError, ValueError) as e:
77
- logging.error(f"Error al serializar context_history para {session_key}: {e}")
78
-
79
- def get_context_history(self, company_short_name: str, user_identifier: str) -> Optional[List[Dict]]:
80
- session_key = self._get_session_key(company_short_name, user_identifier)
99
+ logging.error(f"Error serializing context_history for {session_key}: {e}")
100
+
101
+ def get_context_history(
102
+ self,
103
+ company_short_name: str,
104
+ user_identifier: str,
105
+ model: str = None,
106
+ ) -> Optional[List[Dict]]:
107
+ """Reads and deserializes the context history for this user/model combination."""
108
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
81
109
  if not session_key:
82
- return None
110
+ return []
83
111
 
84
- history_json = RedisSessionManager.hget(session_key, 'context_history')
112
+ history_json = RedisSessionManager.hget(session_key, "context_history")
85
113
  if not history_json:
86
114
  return []
87
115
 
@@ -113,37 +141,61 @@ class UserSessionContextService:
113
141
  except json.JSONDecodeError:
114
142
  return {}
115
143
 
116
- def save_context_version(self, company_short_name: str, user_identifier: str, version: str):
117
- session_key = self._get_session_key(company_short_name, user_identifier)
144
+ def save_context_version(self,
145
+ company_short_name: str,
146
+ user_identifier: str,
147
+ version: str,
148
+ model: str = None,
149
+ ):
150
+ """Saves the context version for this user/model combination."""
151
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
118
152
  if session_key:
119
- RedisSessionManager.hset(session_key, 'context_version', version)
120
-
121
- def get_context_version(self, company_short_name: str, user_identifier: str) -> Optional[str]:
122
- session_key = self._get_session_key(company_short_name, user_identifier)
153
+ RedisSessionManager.hset(session_key, "context_version", version)
154
+
155
+ def get_context_version(self,
156
+ company_short_name: str,
157
+ user_identifier: str,
158
+ model: str = None,
159
+ ) -> Optional[str]:
160
+ """Returns the context version for this user/model combination."""
161
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
123
162
  if not session_key:
124
163
  return None
125
- return RedisSessionManager.hget(session_key, 'context_version')
126
-
127
- def save_prepared_context(self, company_short_name: str, user_identifier: str, context: str, version: str):
128
- """Guarda un contexto de sistema pre-renderizado y su versión, listos para ser enviados al LLM."""
129
- session_key = self._get_session_key(company_short_name, user_identifier)
164
+ return RedisSessionManager.hget(session_key, "context_version")
165
+
166
+ def save_prepared_context(self,
167
+ company_short_name: str,
168
+ user_identifier: str,
169
+ context: str,
170
+ version: str,
171
+ model: str = None,
172
+ ):
173
+ """Stores a pre-rendered system context and its version, ready to be sent to the LLM."""
174
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
130
175
  if session_key:
131
- RedisSessionManager.hset(session_key, 'prepared_context', context)
132
- RedisSessionManager.hset(session_key, 'prepared_context_version', version)
133
-
134
- def get_and_clear_prepared_context(self, company_short_name: str, user_identifier: str) -> tuple:
135
- """Obtiene el contexto preparado y su versión, y los elimina para asegurar que se usan una sola vez."""
136
- session_key = self._get_session_key(company_short_name, user_identifier)
176
+ RedisSessionManager.hset(session_key, "prepared_context", context)
177
+ RedisSessionManager.hset(session_key, "prepared_context_version", version)
178
+
179
+ def get_and_clear_prepared_context(self,
180
+ company_short_name: str,
181
+ user_identifier: str,
182
+ model: str = None,
183
+ ) -> tuple:
184
+ """
185
+ Atomically retrieves the prepared context and its version and then deletes them
186
+ to guarantee they are consumed only once.
187
+ """
188
+ session_key = self._get_session_key(company_short_name, user_identifier, model=model)
137
189
  if not session_key:
138
190
  return None, None
139
191
 
140
192
  pipe = RedisSessionManager.pipeline()
141
- pipe.hget(session_key, 'prepared_context')
142
- pipe.hget(session_key, 'prepared_context_version')
143
- pipe.hdel(session_key, 'prepared_context', 'prepared_context_version')
193
+ pipe.hget(session_key, "prepared_context")
194
+ pipe.hget(session_key, "prepared_context_version")
195
+ pipe.hdel(session_key, "prepared_context", "prepared_context_version")
144
196
  results = pipe.execute()
145
197
 
146
- # results[0] es el contexto, results[1] es la versión
198
+ # results[0] is the context, results[1] is the version
147
199
  return (results[0], results[1]) if results else (None, None)
148
200
 
149
201
  # --- Métodos de Bloqueo ---
@@ -110,13 +110,48 @@ const handleChatMessage = async function () {
110
110
  prompt_name: promptName,
111
111
  client_data: clientData,
112
112
  files: filesBase64.map(f => ({ filename: f.name, content: f.base64 })),
113
- user_identifier: window.user_identifier
113
+ user_identifier: window.user_identifier,
114
+ model: (window.currentLlmModel || window.defaultLlmModel || '')
115
+
114
116
  };
115
117
 
116
118
  const responseData = await callToolkit("/api/llm_query", data, "POST");
117
119
  if (responseData && responseData.answer) {
118
- const answerSection = $('<div>').addClass('answer-section llm-output').append(responseData.answer);
119
- displayBotMessage(answerSection);
120
+ // CAMBIO: contenedor principal para la respuesta del bot
121
+ const botMessageContainer = $('<div>').addClass('bot-message-container');
122
+
123
+ // 1. Si hay reasoning_content, agregar el acordeón colapsable
124
+ if (responseData.reasoning_content) {
125
+ const uniqueId = 'reasoning-' + Date.now(); // ID único para el collapse
126
+
127
+ const reasoningBlock = $(`
128
+ <div class="reasoning-block">
129
+ <button class="reasoning-toggle btn btn-sm btn-link text-decoration-none p-0"
130
+ type="button"
131
+ data-bs-toggle="collapse"
132
+ data-bs-target="#${uniqueId}"
133
+ aria-expanded="false"
134
+ aria-controls="${uniqueId}">
135
+ <i class="bi bi-lightbulb me-1"></i> ${t_js('show_reasoning')}
136
+ </button>
137
+
138
+ <div class="collapse mt-2" id="${uniqueId}">
139
+ <div class="reasoning-card">
140
+ ${responseData.reasoning_content}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ `);
145
+ botMessageContainer.append(reasoningBlock);
146
+ }
147
+
148
+ // 2. Agregar la respuesta final
149
+ const answerSection = $('<div>').addClass('answer-section llm-output').append(responseData.answer);
150
+ botMessageContainer.append(answerSection);
151
+
152
+ // 3. Mostrar el contenedor completo
153
+ displayBotMessage(botMessageContainer);
154
+
120
155
  }
121
156
  } catch (error) {
122
157
  if (error.name === 'AbortError') {
@@ -207,7 +242,12 @@ const toggleSendStopButtons = function (showStop) {
207
242
  * @returns {Promise<object|null>} The response data or null on error.
208
243
  */
209
244
  const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
210
- const url = `${window.iatoolkit_base_url}/${window.companyShortName}${apiPath}`;
245
+ // normalize the url for avoiding double //
246
+ const base = (window.iatoolkit_base_url || '').replace(/\/+$/, '');
247
+ const company = (window.companyShortName || '').replace(/^\/+|\/+$/g, '');
248
+ const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
249
+ const url = `${base}/${company}${path}`;
250
+
211
251
 
212
252
  abortController = new AbortController();
213
253
  const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);