iatoolkit 0.71.4__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 (114) hide show
  1. iatoolkit/__init__.py +19 -7
  2. iatoolkit/base_company.py +1 -71
  3. iatoolkit/cli_commands.py +9 -21
  4. iatoolkit/common/exceptions.py +2 -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 +53 -32
  10. iatoolkit/common/util.py +17 -12
  11. iatoolkit/company_registry.py +55 -14
  12. iatoolkit/{iatoolkit.py → core.py} +102 -72
  13. iatoolkit/infra/{mail_app.py → brevo_mail_app.py} +15 -37
  14. iatoolkit/infra/llm_providers/__init__.py +0 -0
  15. iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
  16. iatoolkit/infra/{gemini_adapter.py → llm_providers/gemini_adapter.py} +11 -17
  17. iatoolkit/infra/{openai_adapter.py → llm_providers/openai_adapter.py} +41 -7
  18. iatoolkit/infra/llm_proxy.py +235 -134
  19. iatoolkit/infra/llm_response.py +5 -0
  20. iatoolkit/locales/en.yaml +134 -4
  21. iatoolkit/locales/es.yaml +293 -162
  22. iatoolkit/repositories/database_manager.py +92 -22
  23. iatoolkit/repositories/document_repo.py +7 -0
  24. iatoolkit/repositories/filesystem_asset_repository.py +36 -0
  25. iatoolkit/repositories/llm_query_repo.py +36 -22
  26. iatoolkit/repositories/models.py +86 -95
  27. iatoolkit/repositories/profile_repo.py +64 -13
  28. iatoolkit/repositories/vs_repo.py +31 -28
  29. iatoolkit/services/auth_service.py +1 -1
  30. iatoolkit/services/branding_service.py +1 -1
  31. iatoolkit/services/company_context_service.py +96 -39
  32. iatoolkit/services/configuration_service.py +329 -67
  33. iatoolkit/services/dispatcher_service.py +51 -227
  34. iatoolkit/services/document_service.py +10 -1
  35. iatoolkit/services/embedding_service.py +9 -6
  36. iatoolkit/services/excel_service.py +50 -2
  37. iatoolkit/services/file_processor_service.py +0 -5
  38. iatoolkit/services/history_manager_service.py +208 -0
  39. iatoolkit/services/jwt_service.py +1 -1
  40. iatoolkit/services/knowledge_base_service.py +412 -0
  41. iatoolkit/services/language_service.py +8 -2
  42. iatoolkit/services/license_service.py +82 -0
  43. iatoolkit/{infra/llm_client.py → services/llm_client_service.py} +42 -29
  44. iatoolkit/services/load_documents_service.py +18 -47
  45. iatoolkit/services/mail_service.py +171 -25
  46. iatoolkit/services/profile_service.py +69 -36
  47. iatoolkit/services/{prompt_manager_service.py → prompt_service.py} +136 -25
  48. iatoolkit/services/query_service.py +229 -203
  49. iatoolkit/services/sql_service.py +116 -34
  50. iatoolkit/services/tool_service.py +246 -0
  51. iatoolkit/services/user_feedback_service.py +18 -6
  52. iatoolkit/services/user_session_context_service.py +121 -51
  53. iatoolkit/static/images/iatoolkit_core.png +0 -0
  54. iatoolkit/static/images/iatoolkit_logo.png +0 -0
  55. iatoolkit/static/js/chat_feedback_button.js +1 -1
  56. iatoolkit/static/js/chat_help_content.js +4 -4
  57. iatoolkit/static/js/chat_main.js +61 -9
  58. iatoolkit/static/js/chat_model_selector.js +227 -0
  59. iatoolkit/static/js/chat_onboarding_button.js +1 -1
  60. iatoolkit/static/js/chat_reload_button.js +4 -1
  61. iatoolkit/static/styles/chat_iatoolkit.css +59 -3
  62. iatoolkit/static/styles/chat_public.css +28 -0
  63. iatoolkit/static/styles/documents.css +598 -0
  64. iatoolkit/static/styles/landing_page.css +223 -7
  65. iatoolkit/static/styles/llm_output.css +34 -1
  66. iatoolkit/system_prompts/__init__.py +0 -0
  67. iatoolkit/system_prompts/query_main.prompt +28 -3
  68. iatoolkit/system_prompts/sql_rules.prompt +47 -12
  69. iatoolkit/templates/_company_header.html +30 -5
  70. iatoolkit/templates/_login_widget.html +3 -3
  71. iatoolkit/templates/base.html +13 -0
  72. iatoolkit/templates/chat.html +45 -3
  73. iatoolkit/templates/forgot_password.html +3 -2
  74. iatoolkit/templates/onboarding_shell.html +1 -2
  75. iatoolkit/templates/signup.html +3 -0
  76. iatoolkit/views/base_login_view.py +8 -3
  77. iatoolkit/views/change_password_view.py +1 -1
  78. iatoolkit/views/chat_view.py +76 -0
  79. iatoolkit/views/forgot_password_view.py +9 -4
  80. iatoolkit/views/history_api_view.py +3 -3
  81. iatoolkit/views/home_view.py +4 -2
  82. iatoolkit/views/init_context_api_view.py +1 -1
  83. iatoolkit/views/llmquery_api_view.py +4 -3
  84. iatoolkit/views/load_company_configuration_api_view.py +49 -0
  85. iatoolkit/views/{file_store_api_view.py → load_document_api_view.py} +15 -11
  86. iatoolkit/views/login_view.py +25 -8
  87. iatoolkit/views/logout_api_view.py +10 -2
  88. iatoolkit/views/prompt_api_view.py +1 -1
  89. iatoolkit/views/rag_api_view.py +216 -0
  90. iatoolkit/views/root_redirect_view.py +22 -0
  91. iatoolkit/views/signup_view.py +12 -4
  92. iatoolkit/views/static_page_view.py +27 -0
  93. iatoolkit/views/users_api_view.py +33 -0
  94. iatoolkit/views/verify_user_view.py +1 -1
  95. iatoolkit-1.4.2.dist-info/METADATA +268 -0
  96. iatoolkit-1.4.2.dist-info/RECORD +133 -0
  97. iatoolkit-1.4.2.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
  98. iatoolkit/repositories/tasks_repo.py +0 -52
  99. iatoolkit/services/history_service.py +0 -37
  100. iatoolkit/services/search_service.py +0 -55
  101. iatoolkit/services/tasks_service.py +0 -188
  102. iatoolkit/templates/about.html +0 -13
  103. iatoolkit/templates/index.html +0 -145
  104. iatoolkit/templates/login_simulation.html +0 -45
  105. iatoolkit/views/external_login_view.py +0 -73
  106. iatoolkit/views/index_view.py +0 -14
  107. iatoolkit/views/login_simulation_view.py +0 -93
  108. iatoolkit/views/tasks_api_view.py +0 -72
  109. iatoolkit/views/tasks_review_api_view.py +0 -55
  110. iatoolkit-0.71.4.dist-info/METADATA +0 -276
  111. iatoolkit-0.71.4.dist-info/RECORD +0 -122
  112. {iatoolkit-0.71.4.dist-info → iatoolkit-1.4.2.dist-info}/WHEEL +0 -0
  113. {iatoolkit-0.71.4.dist-info → iatoolkit-1.4.2.dist-info}/licenses/LICENSE +0 -0
  114. {iatoolkit-0.71.4.dist-info → iatoolkit-1.4.2.dist-info}/top_level.txt +0 -0
@@ -3,21 +3,20 @@
3
3
  #
4
4
  # IAToolkit is open source software.
5
5
 
6
- from iatoolkit.infra.llm_client import llmClient
6
+ from iatoolkit.services.llm_client_service import llmClient
7
7
  from iatoolkit.services.profile_service import ProfileService
8
- from iatoolkit.repositories.document_repo import DocumentRepo
9
8
  from iatoolkit.repositories.profile_repo import ProfileRepo
9
+ from iatoolkit.services.tool_service import ToolService
10
10
  from iatoolkit.services.document_service import DocumentService
11
11
  from iatoolkit.services.company_context_service import CompanyContextService
12
12
  from iatoolkit.services.i18n_service import I18nService
13
13
  from iatoolkit.services.configuration_service import ConfigurationService
14
- from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
15
- from iatoolkit.repositories.models import Task
16
14
  from iatoolkit.services.dispatcher_service import Dispatcher
17
- from iatoolkit.services.prompt_manager_service import PromptService
15
+ from iatoolkit.services.prompt_service import PromptService
18
16
  from iatoolkit.services.user_session_context_service import UserSessionContextService
17
+ from iatoolkit.services.history_manager_service import HistoryManagerService
18
+ from iatoolkit.common.model_registry import ModelRegistry
19
19
  from iatoolkit.common.util import Utility
20
- from iatoolkit.common.exceptions import IAToolkitException
21
20
  from injector import inject
22
21
  import base64
23
22
  import logging
@@ -25,34 +24,42 @@ from typing import Optional
25
24
  import json
26
25
  import time
27
26
  import hashlib
28
- import os
27
+ from dataclasses import dataclass
29
28
 
30
29
 
31
- GEMINI_MAX_TOKENS_CONTEXT_HISTORY = 200000
30
+ @dataclass
31
+ class HistoryHandle:
32
+ """Encapsulates the state needed to manage history for a single turn."""
33
+ company_short_name: str
34
+ user_identifier: str
35
+ type: str
36
+ model: str | None = None
37
+ request_params: dict = None
38
+
32
39
 
33
40
  class QueryService:
34
41
  @inject
35
42
  def __init__(self,
43
+ dispatcher: Dispatcher,
44
+ tool_service: ToolService,
36
45
  llm_client: llmClient,
37
46
  profile_service: ProfileService,
38
47
  company_context_service: CompanyContextService,
39
48
  document_service: DocumentService,
40
- document_repo: DocumentRepo,
41
- llmquery_repo: LLMQueryRepo,
42
49
  profile_repo: ProfileRepo,
43
50
  prompt_service: PromptService,
44
51
  i18n_service: I18nService,
45
- util: Utility,
46
- dispatcher: Dispatcher,
47
52
  session_context: UserSessionContextService,
48
- configuration_service: ConfigurationService
53
+ configuration_service: ConfigurationService,
54
+ history_manager: HistoryManagerService,
55
+ util: Utility,
56
+ model_registry: ModelRegistry
49
57
  ):
50
58
  self.profile_service = profile_service
51
59
  self.company_context_service = company_context_service
52
60
  self.document_service = document_service
53
- self.document_repo = document_repo
54
- self.llmquery_repo = llmquery_repo
55
61
  self.profile_repo = profile_repo
62
+ self.tool_service = tool_service
56
63
  self.prompt_service = prompt_service
57
64
  self.i18n_service = i18n_service
58
65
  self.util = util
@@ -60,35 +67,114 @@ class QueryService:
60
67
  self.session_context = session_context
61
68
  self.configuration_service = configuration_service
62
69
  self.llm_client = llm_client
70
+ self.history_manager = history_manager
71
+ self.model_registry = model_registry
63
72
 
64
- # get the model from the environment variable
65
- self.default_model = os.getenv("LLM_MODEL", "")
66
- if not self.default_model:
67
- raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
68
- "missing ENV variable 'LLM_MODEL' configuration.")
69
73
 
70
- def init_context(self, company_short_name: str,
71
- user_identifier: str,
72
- model: str = None) -> dict:
74
+ def _resolve_model(self, company_short_name: str, model: Optional[str]) -> str:
75
+ # Priority: 1. Explicit model -> 2. Company config
76
+ effective_model = model
77
+ if not effective_model:
78
+ llm_config = self.configuration_service.get_configuration(company_short_name, 'llm')
79
+ if llm_config and llm_config.get('model'):
80
+ effective_model = llm_config['model']
81
+ return effective_model
82
+
83
+ def _get_history_type(self, model: str) -> str:
84
+ history_type_str = self.model_registry.get_history_type(model)
85
+ if history_type_str == "server_side":
86
+ return HistoryManagerService.TYPE_SERVER_SIDE
87
+ else:
88
+ return HistoryManagerService.TYPE_CLIENT_SIDE
89
+
90
+
91
+ def _build_user_facing_prompt(self, company, user_identifier: str,
92
+ client_data: dict, files: list,
93
+ prompt_name: Optional[str], question: str):
94
+ # get the user profile data from the session context
95
+ user_profile = self.profile_service.get_profile_by_identifier(company.short_name, user_identifier)
96
+
97
+ # combine client_data with user_profile
98
+ final_client_data = (user_profile or {}).copy()
99
+ final_client_data.update(client_data)
100
+
101
+ # Load attached files into the context
102
+ files_context = self.load_files_for_context(files)
103
+
104
+ # Initialize prompt_content. It will be an empty string for direct questions.
105
+ main_prompt = ""
106
+ # We use a local variable for the question to avoid modifying the argument reference if it were mutable,
107
+ # although strings are immutable, this keeps the logic clean regarding what 'question' means in each context.
108
+ effective_question = question
109
+
110
+ if prompt_name:
111
+ question_dict = {'prompt': prompt_name, 'data': final_client_data}
112
+ effective_question = json.dumps(question_dict)
113
+ prompt_content = self.prompt_service.get_prompt_content(company, prompt_name)
114
+
115
+ # Render the user requested prompt
116
+ main_prompt = self.util.render_prompt_from_string(
117
+ template_string=prompt_content,
118
+ question=effective_question,
119
+ client_data=final_client_data,
120
+ user_identifier=user_identifier,
121
+ company=company,
122
+ )
73
123
 
74
- # 1. Execute the forced rebuild sequence using the unified identifier.
75
- self.session_context.clear_all_context(company_short_name, user_identifier)
76
- logging.info(f"Context for {company_short_name}/{user_identifier} has been cleared.")
124
+ # This is the final user-facing prompt for this specific turn
125
+ user_turn_prompt = f"{main_prompt}\n{files_context}"
126
+ if not prompt_name:
127
+ user_turn_prompt += f"\n### La pregunta que debes responder es: {effective_question}"
128
+ else:
129
+ user_turn_prompt += f'\n### Contexto Adicional: El usuario ha aportado este contexto puede ayudar: {effective_question}'
130
+
131
+ return user_turn_prompt, effective_question
132
+
133
+ def _ensure_valid_history(self, company,
134
+ user_identifier: str,
135
+ effective_model: str,
136
+ user_turn_prompt: str,
137
+ ignore_history: bool
138
+ ) -> tuple[Optional[HistoryHandle], Optional[dict]]:
139
+ """
140
+ Manages the history strategy and rebuilds context if necessary.
141
+ Returns: (HistoryHandle, error_response)
142
+ """
143
+ history_type = self._get_history_type(effective_model)
77
144
 
78
- # 2. LLM context is clean, now we can load it again
79
- self.prepare_context(
80
- company_short_name=company_short_name,
81
- user_identifier=user_identifier
145
+ # Initialize the handle with base context info
146
+ handle = HistoryHandle(
147
+ company_short_name=company.short_name,
148
+ user_identifier=user_identifier,
149
+ type=history_type,
150
+ model=effective_model
82
151
  )
83
152
 
84
- # 3. communicate the new context to the LLM
85
- response = self.set_context_for_llm(
86
- company_short_name=company_short_name,
87
- user_identifier=user_identifier,
88
- model=model
153
+ # pass the handle to populate request_params
154
+ needs_rebuild = self.history_manager.populate_request_params(
155
+ handle, user_turn_prompt, ignore_history
89
156
  )
90
157
 
91
- return response
158
+ if needs_rebuild:
159
+ logging.warning(f"No valid history for {company.short_name}/{user_identifier}. Rebuilding context...")
160
+
161
+ # try to rebuild the context
162
+ self.prepare_context(company_short_name=company.short_name, user_identifier=user_identifier)
163
+ self.set_context_for_llm(company_short_name=company.short_name, user_identifier=user_identifier,
164
+ model=effective_model)
165
+
166
+ # Retry populating params with the same handle
167
+ needs_rebuild = self.history_manager.populate_request_params(
168
+ handle, user_turn_prompt, ignore_history
169
+ )
170
+
171
+ if needs_rebuild:
172
+ error_key = 'errors.services.context_rebuild_failed'
173
+ error_message = self.i18n_service.t(error_key, company_short_name=company.short_name,
174
+ user_identifier=user_identifier)
175
+ return None, {'error': True, "error_message": error_message}
176
+
177
+ return handle, None
92
178
 
93
179
  def _build_context_and_profile(self, company_short_name: str, user_identifier: str) -> tuple:
94
180
  # this method read the user/company context from the database and renders the system prompt
@@ -106,7 +192,7 @@ class QueryService:
106
192
  question=None,
107
193
  client_data=user_profile,
108
194
  company=company,
109
- service_list=self.dispatcher.get_company_services(company)
195
+ service_list=self.tool_service.get_tools_for_llm(company)
110
196
  )
111
197
 
112
198
  # get the company context: schemas, database models, .md files
@@ -117,6 +203,44 @@ class QueryService:
117
203
 
118
204
  return final_system_context, user_profile
119
205
 
206
+
207
+ def init_context(self, company_short_name: str,
208
+ user_identifier: str,
209
+ model: str = None) -> dict:
210
+ """
211
+ Forces a context rebuild for a given user and (optionally) model.
212
+
213
+ - Clears LLM-related context for the resolved model.
214
+ - Regenerates the static company/user context.
215
+ - Sends the context to the LLM for that model.
216
+ """
217
+
218
+ # 1. Resolve the effective model for this user/company
219
+ effective_model = self._resolve_model(company_short_name, model)
220
+
221
+ # 2. Clear only the LLM-related context for this model
222
+ self.session_context.clear_all_context(company_short_name, user_identifier,model=effective_model)
223
+ logging.info(
224
+ f"Context for {company_short_name}/{user_identifier} "
225
+ f"(model={effective_model}) has been cleared."
226
+ )
227
+
228
+ # 3. Static LLM context is now clean, we can prepare it again (model-agnostic)
229
+ self.prepare_context(
230
+ company_short_name=company_short_name,
231
+ user_identifier=user_identifier
232
+ )
233
+
234
+ # 4. Communicate the new context to the specific LLM model
235
+ response = self.set_context_for_llm(
236
+ company_short_name=company_short_name,
237
+ user_identifier=user_identifier,
238
+ model=effective_model
239
+ )
240
+
241
+ return response
242
+
243
+
120
244
  def prepare_context(self, company_short_name: str, user_identifier: str) -> dict:
121
245
  # prepare the context and decide if it needs to be rebuilt
122
246
  # save the generated context in the session context for later use
@@ -134,46 +258,42 @@ class QueryService:
134
258
  # calculate the context version
135
259
  current_version = self._compute_context_version_from_string(final_system_context)
136
260
 
261
+ # get the current version from the session cache
137
262
  try:
138
263
  prev_version = self.session_context.get_context_version(company_short_name, user_identifier)
139
264
  except Exception:
140
265
  prev_version = None
141
266
 
142
- rebuild_is_needed = not (prev_version and prev_version == current_version and
143
- self._has_valid_cached_context(company_short_name, user_identifier))
144
-
145
- if rebuild_is_needed:
146
- # Guardar el contexto preparado y su versión para que `finalize_context_rebuild` los use.
147
- self.session_context.save_prepared_context(company_short_name,
148
- user_identifier,
149
- final_system_context,
150
- current_version)
267
+ # Determine if we need to persist the prepared context again.
268
+ # If versions match, we assume the artifact is likely safe, but forcing a save
269
+ # on version mismatch ensures data consistency.
270
+ rebuild_is_needed = (prev_version != current_version)
151
271
 
272
+ # Save the prepared context and its version for `set_context_for_llm` to use.
273
+ self.session_context.save_prepared_context(company_short_name,
274
+ user_identifier,
275
+ final_system_context,
276
+ current_version)
152
277
  return {'rebuild_needed': rebuild_is_needed}
153
278
 
154
279
  def set_context_for_llm(self,
155
280
  company_short_name: str,
156
281
  user_identifier: str,
157
282
  model: str = ''):
158
-
159
- # This service takes a pre-built context and send to the LLM
283
+ """
284
+ Takes a pre-built static context and sends it to the LLM for the given model.
285
+ Also initializes the model-specific history through HistoryManagerService.
286
+ """
160
287
  company = self.profile_repo.get_company_by_short_name(company_short_name)
161
288
  if not company:
162
289
  logging.error(f"Company not found: {company_short_name} in set_context_for_llm")
163
290
  return
164
291
 
165
292
  # --- Model Resolution ---
166
- # Priority: 1. Explicit model -> 2. Company config -> 3. Global default
167
- effective_model = model
168
- if not effective_model:
169
- llm_config = self.configuration_service.get_configuration(company_short_name, 'llm')
170
- if llm_config and llm_config.get('model'):
171
- effective_model = llm_config['model']
293
+ effective_model = self._resolve_model(company_short_name, model)
172
294
 
173
- effective_model = effective_model or self.default_model
174
-
175
- # blocking logic to avoid multiple requests for the same user/company at the same time
176
- lock_key = f"lock:context:{company_short_name}/{user_identifier}"
295
+ # Lock per (company, user, model) to avoid concurrent rebuilds for the same model
296
+ lock_key = f"lock:context:{company_short_name}/{user_identifier}/{effective_model}"
177
297
  if not self.session_context.acquire_lock(lock_key, expire_seconds=60):
178
298
  logging.warning(
179
299
  f"try to rebuild context for user {user_identifier} while is still in process, ignored.")
@@ -181,37 +301,29 @@ class QueryService:
181
301
 
182
302
  try:
183
303
  start_time = time.time()
184
- company = self.profile_repo.get_company_by_short_name(company_short_name)
185
304
 
186
305
  # get the prepared context and version from the session cache
187
- prepared_context, version_to_save = self.session_context.get_and_clear_prepared_context(company_short_name,
188
- user_identifier)
306
+ prepared_context, version_to_save = self.session_context.get_and_clear_prepared_context(company_short_name, user_identifier)
189
307
  if not prepared_context:
190
308
  return
191
309
 
192
310
  logging.info(f"sending context to LLM model {effective_model} for: {company_short_name}/{user_identifier}...")
193
311
 
194
- # clean only the chat history and the last response ID for this user/company
195
- self.session_context.clear_llm_history(company_short_name, user_identifier)
196
-
197
- response_id = ''
198
- if self.util.is_gemini_model(effective_model):
199
- context_history = [{"role": "user", "content": prepared_context}]
200
- self.session_context.save_context_history(company_short_name, user_identifier, context_history)
201
- elif self.util.is_openai_model(effective_model):
202
- # Here is the call to the LLM client for settling the company/user context
203
- response_id = self.llm_client.set_company_context(
204
- company=company,
205
- company_base_context=prepared_context,
206
- model=effective_model
207
- )
208
- self.session_context.save_last_response_id(company_short_name, user_identifier, response_id)
312
+ # --- Use Strategy Pattern for History/Context Initialization ---
313
+ history_type = self._get_history_type(effective_model)
314
+ response_data = self.history_manager.initialize_context(
315
+ company_short_name, user_identifier, history_type, prepared_context, company, effective_model
316
+ )
209
317
 
210
318
  if version_to_save:
211
319
  self.session_context.save_context_version(company_short_name, user_identifier, version_to_save)
212
320
 
213
321
  logging.info(
214
322
  f"Context for: {company_short_name}/{user_identifier} settled in {int(time.time() - start_time)} sec.")
323
+
324
+ # Return data (e.g., response_id) if the manager generated any
325
+ return response_data
326
+
215
327
  except Exception as e:
216
328
  logging.exception(f"Error in finalize_context_rebuild for {company_short_name}: {e}")
217
329
  raise e
@@ -219,18 +331,17 @@ class QueryService:
219
331
  # release the lock
220
332
  self.session_context.release_lock(lock_key)
221
333
 
222
- return {'response_id': response_id }
223
334
 
224
335
  def llm_query(self,
225
336
  company_short_name: str,
226
337
  user_identifier: str,
227
- task: Optional[Task] = None,
338
+ model: Optional[str] = None,
228
339
  prompt_name: str = None,
229
340
  question: str = '',
230
341
  client_data: dict = {},
231
- response_id: str = '',
232
- files: list = [],
233
- model: Optional[str] = None) -> dict:
342
+ ignore_history: bool = False,
343
+ files: list = []
344
+ ) -> dict:
234
345
  try:
235
346
  company = self.profile_repo.get_company_by_short_name(short_name=company_short_name)
236
347
  if not company:
@@ -242,86 +353,48 @@ class QueryService:
242
353
  "error_message": self.i18n_service.t('services.start_query')}
243
354
 
244
355
  # --- Model Resolution ---
245
- # Priority: 1. Explicit model -> 2. Company config -> 3. Global default
246
- effective_model = model
247
- if not effective_model:
248
- llm_config = self.configuration_service.get_configuration(company_short_name, 'llm')
249
- if llm_config and llm_config.get('model'):
250
- effective_model = llm_config['model']
251
-
252
- effective_model = effective_model or self.default_model
253
-
254
- # get the previous response_id and context history
255
- previous_response_id = None
256
- context_history = self.session_context.get_context_history(company.short_name, user_identifier) or []
257
-
258
- if self.util.is_openai_model(effective_model):
259
- if response_id:
260
- # context is getting from this response_id
261
- previous_response_id = response_id
262
- else:
263
- # use the full user history context
264
- previous_response_id = self.session_context.get_last_response_id(company.short_name, user_identifier)
265
- if not previous_response_id:
266
- return {'error': True,
267
- "error_message": self.i18n_service.t('errors.services.missing_response_id', company_short_name=company.short_name, user_identifier=user_identifier)
268
- }
269
- elif self.util.is_gemini_model(effective_model):
270
- # check the length of the context_history and remove old messages
271
- self._trim_context_history(context_history)
272
-
273
- # get the user profile data from the session context
274
- user_profile = self.profile_service.get_profile_by_identifier(company.short_name, user_identifier)
275
-
276
- # combine client_data with user_profile
277
- final_client_data = (user_profile or {}).copy()
278
- final_client_data.update(client_data)
279
-
280
- # Load attached files into the context
281
- files_context = self.load_files_for_context(files)
282
-
283
- # Initialize prompt_content. It will be an empty string for direct questions.
284
- main_prompt = ""
285
- if prompt_name:
286
- # For task-based queries, wrap data into a JSON string and get the specific prompt template
287
- question_dict = {'prompt': prompt_name, 'data': final_client_data }
288
- question = json.dumps(question_dict)
289
- prompt_content = self.prompt_service.get_prompt_content(company, prompt_name)
290
-
291
- # Render the main user prompt using the appropriate template (or an empty one)
292
- main_prompt = self.util.render_prompt_from_string(
293
- template_string=prompt_content,
294
- question=question,
295
- client_data=final_client_data,
296
- user_identifier=user_identifier,
297
- company=company,
298
- )
299
-
300
- # This is the final user-facing prompt for this specific turn
301
- user_turn_prompt = f"{main_prompt}\n{files_context}"
302
- if not prompt_name:
303
- user_turn_prompt += f"\n### La pregunta que debes responder es: {question}"
304
- else:
305
- user_turn_prompt += f'\n### Contexto Adicional: El usuario ha aportado este contexto puede ayudar: {question}'
306
-
307
- # add to the history context
308
- if self.util.is_gemini_model(effective_model):
309
- context_history.append({"role": "user", "content": user_turn_prompt})
310
-
311
- # service list for the function calls
312
- tools = self.dispatcher.get_company_services(company)
356
+ effective_model = self._resolve_model(company_short_name, model)
357
+
358
+ # --- Build User-Facing Prompt ---
359
+ user_turn_prompt, effective_question = self._build_user_facing_prompt(
360
+ company=company,
361
+ user_identifier=user_identifier,
362
+ client_data=client_data,
363
+ files=files,
364
+ prompt_name=prompt_name,
365
+ question=question
366
+ )
367
+
368
+ # --- History Management (Strategy Pattern) ---
369
+ history_handle, error_response = self._ensure_valid_history(
370
+ company=company,
371
+ user_identifier=user_identifier,
372
+ effective_model=effective_model,
373
+ user_turn_prompt=user_turn_prompt,
374
+ ignore_history=ignore_history
375
+ )
376
+ if error_response:
377
+ return error_response
378
+
379
+ # get the tools availables for this company
380
+ tools = self.tool_service.get_tools_for_llm(company)
313
381
 
314
382
  # openai structured output instructions
315
383
  output_schema = {}
316
384
 
385
+ # Safely extract parameters for invoke using the handle
386
+ # The handle is guaranteed to have request_params populated if no error returned
387
+ previous_response_id = history_handle.request_params.get('previous_response_id')
388
+ context_history = history_handle.request_params.get('context_history')
389
+
317
390
  # Now send the instructions to the llm
318
391
  response = self.llm_client.invoke(
319
392
  company=company,
320
393
  user_identifier=user_identifier,
321
394
  model=effective_model,
322
395
  previous_response_id=previous_response_id,
323
- context_history=context_history if self.util.is_gemini_model(effective_model) else None,
324
- question=question,
396
+ context_history=context_history,
397
+ question=effective_question,
325
398
  context=user_turn_prompt,
326
399
  tools=tools,
327
400
  text=output_schema
@@ -330,11 +403,10 @@ class QueryService:
330
403
  if not response.get('valid_response'):
331
404
  response['error'] = True
332
405
 
333
- # save last_response_id for the history chain
334
- if "response_id" in response:
335
- self.session_context.save_last_response_id(company.short_name, user_identifier, response["response_id"])
336
- if self.util.is_gemini_model(effective_model):
337
- self.session_context.save_context_history(company.short_name, user_identifier, context_history)
406
+ # save history using the manager passing the handle
407
+ self.history_manager.update_history(
408
+ history_handle, user_turn_prompt, response
409
+ )
338
410
 
339
411
  return response
340
412
  except Exception as e:
@@ -348,23 +420,6 @@ class QueryService:
348
420
  except Exception:
349
421
  return "unknown"
350
422
 
351
- def _has_valid_cached_context(self, company_short_name: str, user_identifier: str) -> bool:
352
- """
353
- Verifica si existe un estado de contexto reutilizable en sesión.
354
- - OpenAI: last_response_id presente.
355
- - Gemini: context_history con al menos 1 mensaje.
356
- """
357
- try:
358
- if self.util.is_openai_model(self.default_model):
359
- prev_id = self.session_context.get_last_response_id(company_short_name, user_identifier)
360
- return bool(prev_id)
361
- if self.util.is_gemini_model(self.default_model):
362
- history = self.session_context.get_context_history(company_short_name, user_identifier) or []
363
- return len(history) >= 1
364
- return False
365
- except Exception as e:
366
- logging.warning(f"error verifying context cache: {e}")
367
- return False
368
423
 
369
424
  def load_files_for_context(self, files: list) -> str:
370
425
  """
@@ -381,7 +436,7 @@ class QueryService:
381
436
  """
382
437
  for document in files:
383
438
  # Support both 'file_id' and 'filename' for robustness
384
- filename = document.get('file_id') or document.get('filename')
439
+ filename = document.get('file_id') or document.get('filename') or document.get('name')
385
440
  if not filename:
386
441
  context += "\n<error>Documento adjunto sin nombre ignorado.</error>\n"
387
442
  continue
@@ -410,32 +465,3 @@ class QueryService:
410
465
 
411
466
  return context
412
467
 
413
- def _trim_context_history(self, context_history: list):
414
- """
415
- Verifica el tamaño del historial de contexto y elimina los mensajes más antiguos
416
- si supera un umbral, conservando siempre el mensaje del sistema (índice 0).
417
- """
418
- if not context_history or len(context_history) <= 1:
419
- return # nothing to remember
420
-
421
- # calculate total tokens
422
- try:
423
- total_tokens = sum(self.llm_client.count_tokens(json.dumps(message)) for message in context_history)
424
- except Exception as e:
425
- logging.error(f"error counting tokens for history: {e}.")
426
- return
427
-
428
- # Si se excede el límite, eliminar mensajes antiguos (empezando por el segundo)
429
- while total_tokens > GEMINI_MAX_TOKENS_CONTEXT_HISTORY and len(context_history) > 1:
430
- try:
431
- # Eliminar el mensaje más antiguo después del prompt del sistema
432
- removed_message = context_history.pop(1)
433
- removed_tokens = self.llm_client.count_tokens(json.dumps(removed_message))
434
- total_tokens -= removed_tokens
435
- logging.warning(
436
- f"history tokens ({total_tokens + removed_tokens} tokens) exceed the limit of: {GEMINI_MAX_TOKENS_CONTEXT_HISTORY}. "
437
- f"new context: {total_tokens} tokens."
438
- )
439
- except IndexError:
440
- # Se produce si solo queda el mensaje del sistema, el bucle debería detenerse.
441
- break