iatoolkit 1.9.0__py3-none-any.whl → 1.15.3__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 (38) hide show
  1. iatoolkit/__init__.py +1 -1
  2. iatoolkit/common/routes.py +1 -1
  3. iatoolkit/common/util.py +8 -123
  4. iatoolkit/core.py +1 -0
  5. iatoolkit/infra/connectors/file_connector.py +10 -2
  6. iatoolkit/infra/connectors/google_drive_connector.py +3 -0
  7. iatoolkit/infra/connectors/local_file_connector.py +3 -0
  8. iatoolkit/infra/connectors/s3_connector.py +24 -1
  9. iatoolkit/infra/llm_providers/deepseek_adapter.py +17 -1
  10. iatoolkit/infra/llm_providers/gemini_adapter.py +117 -18
  11. iatoolkit/infra/llm_providers/openai_adapter.py +175 -18
  12. iatoolkit/infra/llm_response.py +13 -0
  13. iatoolkit/locales/en.yaml +47 -2
  14. iatoolkit/locales/es.yaml +45 -1
  15. iatoolkit/repositories/llm_query_repo.py +44 -33
  16. iatoolkit/services/company_context_service.py +294 -133
  17. iatoolkit/services/dispatcher_service.py +1 -1
  18. iatoolkit/services/knowledge_base_service.py +26 -4
  19. iatoolkit/services/llm_client_service.py +58 -2
  20. iatoolkit/services/prompt_service.py +236 -330
  21. iatoolkit/services/query_service.py +37 -18
  22. iatoolkit/services/storage_service.py +92 -0
  23. iatoolkit/static/js/chat_filepond.js +188 -63
  24. iatoolkit/static/js/chat_main.js +105 -52
  25. iatoolkit/static/styles/chat_iatoolkit.css +96 -0
  26. iatoolkit/system_prompts/query_main.prompt +24 -41
  27. iatoolkit/templates/chat.html +15 -6
  28. iatoolkit/views/base_login_view.py +1 -1
  29. iatoolkit/views/categories_api_view.py +43 -3
  30. iatoolkit/views/chat_view.py +1 -1
  31. iatoolkit/views/login_view.py +1 -1
  32. iatoolkit/views/prompt_api_view.py +1 -1
  33. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/METADATA +1 -1
  34. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/RECORD +38 -37
  35. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/WHEEL +0 -0
  36. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE +0 -0
  37. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
  38. {iatoolkit-1.9.0.dist-info → iatoolkit-1.15.3.dist-info}/top_level.txt +0 -0
@@ -99,7 +99,7 @@ class QueryService:
99
99
  final_client_data.update(client_data)
100
100
 
101
101
  # Load attached files into the context
102
- files_context = self.load_files_for_context(files)
102
+ files_context, images = self.load_files_for_context(files)
103
103
 
104
104
  # Initialize prompt_content. It will be an empty string for direct questions.
105
105
  main_prompt = ""
@@ -128,7 +128,7 @@ class QueryService:
128
128
  else:
129
129
  user_turn_prompt += f'\n### Contexto Adicional: El usuario ha aportado este contexto puede ayudar: {effective_question}'
130
130
 
131
- return user_turn_prompt, effective_question
131
+ return user_turn_prompt, effective_question, images
132
132
 
133
133
  def _ensure_valid_history(self, company,
134
134
  user_identifier: str,
@@ -356,7 +356,7 @@ class QueryService:
356
356
  effective_model = self._resolve_model(company_short_name, model)
357
357
 
358
358
  # --- Build User-Facing Prompt ---
359
- user_turn_prompt, effective_question = self._build_user_facing_prompt(
359
+ user_turn_prompt, effective_question, images = self._build_user_facing_prompt(
360
360
  company=company,
361
361
  user_identifier=user_identifier,
362
362
  client_data=client_data,
@@ -397,7 +397,8 @@ class QueryService:
397
397
  question=effective_question,
398
398
  context=user_turn_prompt,
399
399
  tools=tools,
400
- text=output_schema
400
+ text=output_schema,
401
+ images=images,
401
402
  )
402
403
 
403
404
  if not response.get('valid_response'):
@@ -421,24 +422,23 @@ class QueryService:
421
422
  return "unknown"
422
423
 
423
424
 
424
- def load_files_for_context(self, files: list) -> str:
425
+ def load_files_for_context(self, files: list) -> tuple[str, list]:
425
426
  """
426
- Processes a list of attached files, decodes their content,
427
- and formats them into a string context for the LLM.
427
+ Processes a list of attached files.
428
+ Decodes text documents into context string and separates images for multimodal processing.
428
429
  """
429
430
  if not files:
430
- return ''
431
+ return '', []
432
+
433
+ context_parts = []
434
+ images = []
435
+ text_files_count = 0
431
436
 
432
- context = f"""
433
- A continuación encontraras una lista de documentos adjuntos
434
- enviados por el usuario que hace la pregunta,
435
- en total son: {len(files)} documentos adjuntos
436
- """
437
437
  for document in files:
438
438
  # Support both 'file_id' and 'filename' for robustness
439
439
  filename = document.get('file_id') or document.get('filename') or document.get('name')
440
440
  if not filename:
441
- context += "\n<error>Documento adjunto sin nombre ignorado.</error>\n"
441
+ context_parts.append("\n<error>Documento adjunto sin nombre ignorado.</error>\n")
442
442
  continue
443
443
 
444
444
  # Support both 'base64' and 'content' for robustness
@@ -446,7 +446,12 @@ class QueryService:
446
446
 
447
447
  if not base64_content:
448
448
  # Handles the case where a file is referenced but no content is provided
449
- context += f"\n<error>El archivo '{filename}' no fue encontrado y no pudo ser cargado.</error>\n"
449
+ context_parts.append(f"\n<error>El archivo '{filename}' no fue encontrado y no pudo ser cargado.</error>\n")
450
+ continue
451
+
452
+ # Detect if the file is an image
453
+ if self._is_image(filename):
454
+ images.append({'name': filename, 'base64': base64_content})
450
455
  continue
451
456
 
452
457
  try:
@@ -456,12 +461,26 @@ class QueryService:
456
461
 
457
462
  file_content = base64.b64decode(base64_content)
458
463
  document_text = self.document_service.file_to_txt(filename, file_content)
459
- context += f"\n<document name='{filename}'>\n{document_text}\n</document>\n"
464
+ context_parts.append(f"\n<document name='{filename}'>\n{document_text}\n</document>\n")
465
+ text_files_count += 1
460
466
  except Exception as e:
461
467
  # Catches errors from b64decode or file_to_txt
462
468
  logging.error(f"Failed to process file {filename}: {e}")
463
- context += f"\n<error>Error al procesar el archivo {filename}: {str(e)}</error>\n"
469
+ context_parts.append(f"\n<error>Error al procesar el archivo {filename}: {str(e)}</error>\n")
464
470
  continue
465
471
 
466
- return context
472
+ context = ""
473
+ if text_files_count > 0:
474
+ context = f"""
475
+ A continuación encontraras una lista de documentos adjuntos
476
+ enviados por el usuario que hace la pregunta,
477
+ en total son: {text_files_count} documentos adjuntos
478
+ """ + "".join(context_parts)
479
+ elif context_parts:
480
+ # If only errors were collected
481
+ context = "".join(context_parts)
482
+
483
+ return context, images
467
484
 
485
+ def _is_image(self, filename: str) -> bool:
486
+ return filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ import base64
7
+ import uuid
8
+ import logging
9
+ import mimetypes
10
+ import os
11
+ from injector import inject
12
+ from typing import Dict
13
+ from iatoolkit.infra.connectors.s3_connector import S3Connector
14
+ from iatoolkit.common.exceptions import IAToolkitException
15
+
16
+
17
+ class StorageService:
18
+ """
19
+ High level service for managing assets storage (images generated, attachments, etc).
20
+ Provides abstraction for file decoding and naming.
21
+ """
22
+
23
+ @inject
24
+ def __init__(self):
25
+ self.connector = self._init_connector()
26
+
27
+ def _init_connector(self) -> S3Connector:
28
+ # We configure S3 directly using environment variables
29
+ bucket = os.getenv("S3_BUCKET_NAME", "iatoolkit-assets")
30
+
31
+ auth = {
32
+ 'aws_access_key_id': os.getenv('AWS_ACCESS_KEY_ID'),
33
+ 'aws_secret_access_key': os.getenv('AWS_SECRET_ACCESS_KEY'),
34
+ 'region_name': os.getenv('AWS_REGION', 'us-east-1')
35
+ }
36
+
37
+ return S3Connector(
38
+ bucket=bucket,
39
+ prefix="", # Empty prefix to allow full control over keys
40
+ folder="",
41
+ auth=auth
42
+ )
43
+
44
+ def store_generated_image(self, company_short_name: str, base64_data: str, mime_type: str) -> Dict[str, str]:
45
+ """
46
+ Guarda una imagen generada por LLM (Base64) en el storage.
47
+
48
+ Returns:
49
+ Dict con:
50
+ - 'storage_key': La ruta interna (para guardar en BD)
51
+ - 'url': La URL firmada (para devolver al frontend inmediatamente)
52
+ """
53
+ try:
54
+ # 1. Decode Base64
55
+ # Sometimes the string comes with header 'data:image/png;base64,...', clean it
56
+ if "base64," in base64_data:
57
+ base64_data = base64_data.split("base64,")[1]
58
+
59
+ image_bytes = base64.b64decode(base64_data)
60
+
61
+ # 2. Generate unique name
62
+ ext = mimetypes.guess_extension(mime_type) or ".png"
63
+ filename = f"{uuid.uuid4()}{ext}"
64
+
65
+ # 3. Define folder structure: companies/{company}/generated/{filename}
66
+ storage_key = f"companies/{company_short_name}/generated_images/{filename}"
67
+
68
+ # 4. Upload
69
+ self.connector.upload_file(
70
+ file_path=storage_key,
71
+ content=image_bytes,
72
+ content_type=mime_type
73
+ )
74
+
75
+ logging.info(f"Generated image saved at: {storage_key}")
76
+
77
+ # 5. Generate temporary URL
78
+ url = self.connector.generate_presigned_url(storage_key)
79
+
80
+ return {
81
+ "storage_key": storage_key,
82
+ "url": url
83
+ }
84
+
85
+ except Exception as e:
86
+ error_msg = f"Error saving image to Storage: {str(e)}"
87
+ logging.error(error_msg)
88
+ raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR, error_msg)
89
+
90
+ def get_public_url(self, storage_key: str) -> str:
91
+ """Gets a fresh signed URL for an existing resource."""
92
+ return self.connector.generate_presigned_url(storage_key)
@@ -1,85 +1,210 @@
1
- $(document).ready(function () {
2
- const paperclipButton = $('#paperclip-button');
3
- const viewFilesButtonContainer = $('#view-files-button-container');
4
- const viewFilesButton = $('#view-files-button');
5
- const uploadedFilesModalElement = $('#uploadedFilesModal');
6
- const uploadedFilesModal = uploadedFilesModalElement; // En Bootstrap 3, el elemento jQuery es el modal
7
- const uploadedFilesList = $('#uploaded-files-list');
8
-
9
- // Initialize FilePond
10
- window.filePond = FilePond.create(
11
- document.querySelector('#file-upload'), {
1
+ document.addEventListener('DOMContentLoaded', function () {
2
+ // 1. Register FilePond Plugins
3
+ // Ensure plugin scripts are loaded in your base layout or template
4
+ if (typeof FilePondPluginFileEncode !== 'undefined') {
5
+ FilePond.registerPlugin(FilePondPluginFileEncode);
6
+ }
7
+ if (typeof FilePondPluginFileValidateSize !== 'undefined') {
8
+ FilePond.registerPlugin(FilePondPluginFileValidateSize);
9
+ }
10
+ if (typeof FilePondPluginFileValidateType !== 'undefined') {
11
+ FilePond.registerPlugin(FilePondPluginFileValidateType);
12
+ }
13
+ if (typeof FilePondPluginImagePreview !== 'undefined') {
14
+ FilePond.registerPlugin(FilePondPluginImagePreview);
15
+ }
16
+
17
+ // 2. Create FilePond instance on the hidden input
18
+ const inputElement = document.querySelector('input.filepond');
19
+
20
+ // FilePond base configuration
21
+ const filePond = FilePond.create(inputElement, {
12
22
  allowMultiple: true,
13
- labelIdle: '',
23
+ maxFiles: 5,
24
+ maxFileSize: '30MB',
25
+
26
+ // Extensive list of accepted types (Images, PDF, Text, Excel, Word)
27
+ acceptedFileTypes: [
28
+ 'image/*',
29
+ 'application/pdf',
30
+ 'text/plain',
31
+ 'text/csv',
32
+ 'application/vnd.ms-excel', // .xls
33
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
34
+ 'application/msword', // .doc
35
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx
36
+ ],
37
+ labelIdle: '', // Left empty because we use our own UI
14
38
  credits: false,
15
- allowFileSizeValidation: true,
16
- maxFileSize: '10MB',
17
- stylePanelLayout: null,
18
- itemInsertLocation: 'after',
19
- instantUpload: false,
39
+ storeAsFile: true, // Important for encode to work if used
20
40
  });
21
41
 
22
- $('.filepond--root').hide(); // Ocultar la UI de FilePond
42
+ // Expose globally so chat_main.js can access (getFiles, removeFiles)
43
+ window.filePond = filePond;
44
+
45
+
46
+ // 3. DOM references for the new custom UI
47
+ const dropzone = document.getElementById('chat-dropzone');
48
+ const fileListContainer = document.getElementById('inline-file-list');
49
+ const fileCounter = document.getElementById('file-counter');
50
+ const paperclipBtn = document.getElementById('paperclip-button');
51
+
52
+
53
+ // 4. Rendering Functions
54
+
55
+ /**
56
+ * Returns the Bootstrap icon class based on filename or file type.
57
+ */
58
+ function getFileIconClass(filename, type) {
59
+ const ext = filename.split('.').pop().toLowerCase();
60
+
61
+ // Images
62
+ if (type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
63
+ return 'bi-file-earmark-image';
64
+ }
65
+ // PDF
66
+ if (type === 'application/pdf' || ext === 'pdf') {
67
+ return 'bi-file-earmark-pdf';
68
+ }
69
+ // Excel / Spreadsheets
70
+ if (['xls', 'xlsx', 'csv'].includes(ext) || type.includes('spreadsheet') || type.includes('excel')) {
71
+ return 'bi-file-earmark-excel';
72
+ }
73
+ // Word / Documents
74
+ if (['doc', 'docx'].includes(ext) || type.includes('word') || type.includes('document')) {
75
+ return 'bi-file-earmark-word';
76
+ }
77
+ // Text / Code
78
+ if (['txt', 'md', 'json', 'py', 'js', 'html', 'css'].includes(ext)) {
79
+ return 'bi-file-earmark-text';
80
+ }
81
+
82
+ // Default icon
83
+ return 'bi-file-earmark';
84
+ }
85
+
86
+ /**
87
+ * Rebuilds the visual file list below the input.
88
+ */
89
+ function renderFileList() {
90
+ if (!fileListContainer) return;
23
91
 
24
- // Función para actualizar la visibilidad del icono "ver archivos"
25
- function updateFileIconsVisibility() {
26
92
  const files = filePond.getFiles();
93
+ fileListContainer.innerHTML = ''; // Clear current list
94
+
27
95
  if (files.length > 0) {
28
- viewFilesButtonContainer.show();
29
- } else {
30
- viewFilesButtonContainer.hide();
31
- if (uploadedFilesModalElement.hasClass('in')) { // Si el modal está abierto y no hay archivos, ciérralo
32
- uploadedFilesModal.modal('hide');
96
+ fileListContainer.style.display = 'block';
97
+
98
+ // Update counter
99
+ if (fileCounter) {
100
+ fileCounter.textContent = `${files.length}/${filePond.maxFiles || 5}`;
101
+ fileCounter.style.display = 'inline-block';
33
102
  }
103
+
104
+ files.forEach(fileItem => {
105
+ const file = fileItem.file;
106
+ const iconClass = getFileIconClass(file.name, file.type || '');
107
+
108
+ // Create file row
109
+ const row = document.createElement('div');
110
+ row.className = 'file-list-item';
111
+
112
+ row.innerHTML = `
113
+ <i class="bi ${iconClass} file-icon"></i>
114
+ <span class="file-name" title="${file.name}">${file.name}</span>
115
+ <i class="bi bi-x-circle-fill file-remove" role="button" aria-label="Remove file"></i>
116
+ `;
117
+
118
+ // Click event on the remove button of the row
119
+ const removeBtn = row.querySelector('.file-remove');
120
+ removeBtn.addEventListener('click', (e) => {
121
+ e.stopPropagation(); // Prevent click propagation if nested in a clickable area
122
+ filePond.removeFile(fileItem.id);
123
+ });
124
+
125
+ fileListContainer.appendChild(row);
126
+ });
127
+ } else {
128
+ // Hide list and counter if no files
129
+ fileListContainer.style.display = 'none';
130
+ if (fileCounter) fileCounter.style.display = 'none';
34
131
  }
35
132
  }
36
133
 
37
- // Función para poblar el modal con los archivos y botones de eliminar
38
- function populateFilesModal() {
39
- uploadedFilesList.empty(); // Limpiar lista anterior
40
- const files = filePond.getFiles();
41
134
 
42
- if (files.length === 0) {
43
- uploadedFilesList.append('<li class="list-group-item">No hay archivos adjuntos.</li>');
44
- return;
45
- }
135
+ // 5. Interaction Event Management
46
136
 
47
- files.forEach(file => {
48
- const listItem = $(`
49
- <li class="list-group-item d-flex justify-content-between align-items-center">
50
- <span class="file-name-modal">${file.filename}</span>
51
- <button type="button" class="btn btn-sm btn-outline-danger remove-file-btn" data-file-id="${file.id}" title="Eliminar archivo">
52
- <i class="bi bi-trash-fill"></i>
53
- </button>
54
- </li>
55
- `);
56
- uploadedFilesList.append(listItem);
137
+ // -- Custom Dropzone Robust Handling --
138
+ if (dropzone) {
139
+ // Prevent default browser behavior for drag events
140
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
141
+ dropzone.addEventListener(eventName, (e) => {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ }, false);
145
+ });
146
+
147
+ // Highlight dropzone on drag enter/over
148
+ ['dragenter', 'dragover'].forEach(eventName => {
149
+ dropzone.addEventListener(eventName, () => {
150
+ dropzone.classList.add('drag-over');
151
+ }, false);
152
+ });
153
+
154
+ // Remove highlight on drag leave or drop
155
+ // Note: checking relatedTarget prevents flickering when dragging over child elements
156
+ dropzone.addEventListener('dragleave', (e) => {
157
+ if (!dropzone.contains(e.relatedTarget)) {
158
+ dropzone.classList.remove('drag-over');
159
+ }
160
+ }, false);
161
+
162
+ dropzone.addEventListener('drop', (e) => {
163
+ dropzone.classList.remove('drag-over');
164
+
165
+ // Pass dropped files to FilePond
166
+ // We convert FileList to Array to ensure compatibility
167
+ if (e.dataTransfer && e.dataTransfer.files.length > 0) {
168
+ filePond.addFiles(Array.from(e.dataTransfer.files));
169
+ }
170
+ }, false);
171
+
172
+ // Click on zone opens native selector
173
+ dropzone.addEventListener('click', function() {
174
+ filePond.browse();
175
+ });
176
+ }
177
+
178
+ // -- "Clip" Button (Legacy design compatibility) --
179
+ if (paperclipBtn) {
180
+ paperclipBtn.addEventListener('click', function() {
181
+ filePond.browse();
57
182
  });
58
183
  }
59
184
 
60
- // Event listeners de FilePond
61
- window.filePond.on('addfile', () => updateFileIconsVisibility());
62
- window.filePond.on('removefile', () => {
63
- updateFileIconsVisibility();
64
- if (uploadedFilesModalElement.hasClass('in')) {
65
- populateFilesModal();
185
+
186
+ // 6. FilePond Hooks for Reactivity
187
+
188
+ // On file add (even if validation error, FilePond manages array)
189
+ filePond.on('addfile', (error, file) => {
190
+ if (error) {
191
+ console.error('FilePond Error:', error);
192
+ // Optional: Show error toast if file is invalid (e.g., too large)
193
+ return;
66
194
  }
195
+ renderFileList();
67
196
  });
68
197
 
69
- // Event listeners de los botones de la UI
70
- paperclipButton.on('click', () => window.filePond.browse());
71
- viewFilesButton.on('click', () => {
72
- populateFilesModal();
73
- uploadedFilesModal.modal('show');
74
- });
75
- uploadedFilesList.on('click', '.remove-file-btn', function () {
76
- const fileIdToRemove = $(this).data('file-id');
77
- if (fileIdToRemove) {
78
- window.filePond.removeFile(fileIdToRemove);
79
- }
198
+ // On file remove
199
+ filePond.on('removefile', (error, file) => {
200
+ renderFileList();
80
201
  });
81
202
 
82
- // Inicializar visibilidad al cargar
83
- updateFileIconsVisibility();
84
- });
203
+ // Event for general error (e.g., type not allowed on drop)
204
+ filePond.on('warning', (error) => {
205
+ console.warn('FilePond Warning:', error);
206
+ });
85
207
 
208
+ // Initialization: Initial render in case browser cached state
209
+ renderFileList();
210
+ });