iatoolkit 1.7.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 (41) hide show
  1. iatoolkit/__init__.py +1 -1
  2. iatoolkit/common/routes.py +16 -3
  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 +82 -4
  14. iatoolkit/locales/es.yaml +79 -4
  15. iatoolkit/repositories/llm_query_repo.py +51 -18
  16. iatoolkit/repositories/models.py +16 -7
  17. iatoolkit/services/company_context_service.py +294 -133
  18. iatoolkit/services/configuration_service.py +140 -121
  19. iatoolkit/services/dispatcher_service.py +1 -4
  20. iatoolkit/services/knowledge_base_service.py +26 -4
  21. iatoolkit/services/llm_client_service.py +58 -2
  22. iatoolkit/services/prompt_service.py +251 -164
  23. iatoolkit/services/query_service.py +37 -18
  24. iatoolkit/services/storage_service.py +92 -0
  25. iatoolkit/static/js/chat_filepond.js +188 -63
  26. iatoolkit/static/js/chat_main.js +105 -52
  27. iatoolkit/static/styles/chat_iatoolkit.css +96 -0
  28. iatoolkit/system_prompts/query_main.prompt +24 -41
  29. iatoolkit/templates/chat.html +15 -6
  30. iatoolkit/views/base_login_view.py +1 -1
  31. iatoolkit/views/categories_api_view.py +111 -0
  32. iatoolkit/views/chat_view.py +1 -1
  33. iatoolkit/views/configuration_api_view.py +1 -1
  34. iatoolkit/views/login_view.py +1 -1
  35. iatoolkit/views/prompt_api_view.py +88 -7
  36. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/METADATA +1 -1
  37. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/RECORD +41 -39
  38. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/WHEEL +0 -0
  39. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE +0 -0
  40. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
  41. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/top_level.txt +0 -0
@@ -303,6 +303,102 @@ li {
303
303
  color: #343a40;
304
304
  }
305
305
 
306
+ /* --- NUEVO: Dropzone y Lista de Archivos Inline --- */
307
+
308
+ /* Contenedor de la lista de archivos */
309
+ .file-list-inline {
310
+ margin-bottom: 0.5rem;
311
+ display: none; /* Se oculta si no hay archivos */
312
+ }
313
+
314
+ .file-list-item {
315
+ background-color: #fff;
316
+ border: 1px solid #dee2e6;
317
+ border-radius: 6px;
318
+ padding: 6px 10px;
319
+ margin-bottom: 5px;
320
+ display: flex;
321
+ align-items: center;
322
+ font-size: 0.9rem;
323
+ transition: background-color 0.2s;
324
+ }
325
+
326
+ .file-list-item:hover {
327
+ background-color: #f8f9fa;
328
+ }
329
+
330
+ .file-list-item i.file-icon {
331
+ font-size: 1.1rem;
332
+ margin-right: 8px;
333
+ color: var(--brand-primary-color);
334
+ }
335
+
336
+ .file-list-item .file-name {
337
+ flex-grow: 1;
338
+ white-space: nowrap;
339
+ overflow: hidden;
340
+ text-overflow: ellipsis;
341
+ margin-right: 10px;
342
+ color: #495057;
343
+ }
344
+
345
+ .file-list-item .file-remove {
346
+ cursor: pointer;
347
+ color: #dc3545; /* Rojo Bootstrap estándar */
348
+ opacity: 0.6;
349
+ transition: opacity 0.2s, transform 0.2s;
350
+ }
351
+
352
+ .file-list-item .file-remove:hover {
353
+ opacity: 1;
354
+ transform: scale(1.1);
355
+ }
356
+
357
+ /* Dropzone estática */
358
+ .chat-dropzone {
359
+ border: 2px dashed #ced4da;
360
+ border-radius: 0.5rem;
361
+ padding: 10px;
362
+ text-align: center;
363
+ background-color: rgba(255, 255, 255, 0.5);
364
+ color: #6c757d;
365
+ cursor: pointer;
366
+ transition: all 0.2s ease-in-out;
367
+ margin-top: 0.5rem;
368
+ font-size: 0.9rem;
369
+ }
370
+
371
+ .chat-dropzone:hover {
372
+ border-color: var(--brand-primary-color);
373
+ background-color: rgba(255, 255, 255, 0.8);
374
+ color: var(--brand-primary-color);
375
+ }
376
+
377
+ .chat-dropzone.drag-over {
378
+ border-color: var(--brand-primary-color);
379
+ background-color: #e9ecef;
380
+ transform: scale(1.01);
381
+ }
382
+
383
+ .chat-dropzone i {
384
+ font-size: 1.2rem;
385
+ vertical-align: middle;
386
+ margin-right: 5px;
387
+ }
388
+
389
+ /* Ajuste para el contador de archivos */
390
+ .dropzone-counter {
391
+ font-size: 0.8rem;
392
+ margin-left: 5px;
393
+ background: #e9ecef;
394
+ padding: 2px 6px;
395
+ border-radius: 10px;
396
+ color: #495057;
397
+ }
398
+
399
+ /* --- FIN NUEVO --- */
400
+
401
+
306
402
  /* Anulación específica para el botón de ENVIAR usando su ID (Máxima Prioridad) */
307
403
  #send-button i {
308
404
  font-size: 1.7rem; /* Ligeramente más grande */
@@ -1,4 +1,5 @@
1
- Eres un asistente que responde preguntas o ejecuta tareas según el contexto de la empresa.
1
+ Eres un asistente que responde preguntas o ejecuta tareas según el contexto
2
+ de la empresa.
2
3
 
3
4
  ### **Nombre de la empresa**
4
5
  ## Nombre: {{company}}, tambien se conoce como **{{ company_short_name }}**
@@ -12,9 +13,10 @@ Eres un asistente que responde preguntas o ejecuta tareas según el contexto de
12
13
  - Rol de usuario: {{ user_rol }}
13
14
 
14
15
 
15
- ## 🔧 Servicios de datos disponibles en {{ company.name }}
16
+ ## 🔧 Servicios de datos disponibles en {{ company_short_name }}
16
17
 
17
- A continuación se muestran los *function calls* (tools) que puedes usar para resolver tareas relacionadas con datos.
18
+ A continuación se muestran los *function calls* (tools) que tienes
19
+ disponibles para resolver consultas relacionadas con datos de la empresa.
18
20
  Cada servicio incluye su **nombre**, **propósito** y **parámetros esperados**.
19
21
 
20
22
  ### 📌 LISTA DE TOOLS DISPONIBLES
@@ -26,49 +28,30 @@ Cada servicio incluye su **nombre**, **propósito** y **parámetros esperados**.
26
28
 
27
29
  ---
28
30
 
29
- ### ⚠️ REGLAS IMPORTANTES PARA EL USO DE TOOLS
31
+ 1. **NO inventes información** si un tool existe para obtenerla.
32
+ 2. Si un usuario solicita datos específicos, **elige el tool cuyo propósito coincida exactamente** con la solicitud.
33
+ 3. **Si ningún tool aplica**, responde siguiendo el estilo de un asistente normal.
30
34
 
31
- 1. **Debes usar un tool cuando la tarea lo requiera.**
32
- Ejemplos:
33
- - Consultar bases de datos
34
- - Obtener información corporativa
35
- - Ejecutar SQL
36
- - Cargar documentos
37
-
38
- 2. **NO inventes información** si un tool existe para obtenerla.
39
- 3. Si un usuario solicita datos específicos, **elige el tool cuyo propósito coincida exactamente** con la solicitud.
40
- 4. **Si ningún tool aplica**, responde siguiendo el estilo de un asistente normal.
41
-
42
- ---
43
-
44
- Eres un asistente que responde preguntas sobre empresas y sus clientes.
45
35
 
46
36
  **Reglas obligatorias de contexto:**
47
37
  En caso que te hagan preguntas especificas sobre un cliente, debes asumir que la pregunta se
48
38
  refiere al **último cliente identificado** en la conversación.
49
39
 
50
- **IMPORTANTE:**
51
-
52
- ### **Instrucciones**
53
- 1. Devuelve siempre la respuesta en formato JSON.
54
- 2. Usa la información de contexto para responder la consulta del usuario de forma clara y concisa.
55
- Si el contexto o los adjuntos no te proveen información para la respuesta utiliza tu conocimiento propio.
56
- 3. Genera una única `"answer"` que integre la información de todos los servicios relevantes.
57
- 4. En `"aditional_data"`, a menos que se te indique lo contrario retorna un diccionario vacio: {}
58
- 5. El JSON de salida solo puede tener dos llaves: `"answer"` y `"aditional_data"`.
59
- 6. `"answer"` siempre debe contener un unico string con tu respuesta
60
- 7. Devuelve **únicamente** la respuesta en JSON válido, sin texto adicional.
61
- 7. **NO devuelvas el JSON como un string ni como texto escapado.**
62
- 9. **NO incluyas delimitadores como \`\`\`, \`\`\`json, ni comillas alrededor del objeto JSON.**
63
- 10. **NO escribas ninguna explicación ni texto fuera del JSON. Devuelve solo el objeto JSON.**
64
-
65
-
66
- ### **Ejemplo de salida esperada**
67
- {
68
- "answer": "El iPhone 15 Pro está disponible por 1099 USD y tu ticket de soporte está en proceso.",
69
- "aditional_data": {}
70
- }
71
-
72
-
40
+ ---
41
+ ### **Instrucciones de Formato de Salida**
42
+
43
+ 1. **Estructura Preferida (JSON):**
44
+ Para la parte **textual** de tu respuesta, utiliza preferentemente el siguiente formato JSON.
45
+ {
46
+ "answer": "Tu respuesta final al usuario. Usa Markdown para formato (negritas, listas, tablas).",
47
+ "aditional_data": {}
48
+ }
49
+
50
+ 2. **Generación de Imágenes y Contenido Multimodal:**
51
+ - Si el usuario te pide generar una imagen y tienes la capacidad nativa **HAZLO**.
52
+ - Genera la imagen como un bloque de contenido separado.
53
+ - **NO** intentes meter la imagen dentro del JSON.
54
+ - En el campo "answer" del JSON, describe o presenta la imagen que has generado.
55
+ - incluye la imagen como parte multimodal (output_image / file / base64) en la respuesta.
73
56
 
74
57
 
@@ -208,12 +208,7 @@
208
208
  title="{{ t('ui.tooltips.attach_files') }}">
209
209
  <i class="bi bi-plus-circle"></i>
210
210
  </a>
211
- <div id="view-files-button-container" style="display: none;">
212
- <a class="p-2" href="javascript:void(0);" id="view-files-button"
213
- title="{{ t('ui.tooltips.view_attached_files') }}">
214
- <i class="bi bi-file-earmark-text"></i>
215
- </a>
216
- </div>
211
+
217
212
  </div>
218
213
 
219
214
  <!-- Textarea Central -->
@@ -238,6 +233,20 @@
238
233
  </div>
239
234
  </div>
240
235
  </div>
236
+
237
+ <!-- NUEVO: Área de Adjuntos (Lista + Dropzone) -->
238
+ <div id="attachments-wrapper" class="px-1">
239
+ <!-- Lista de archivos cargados -->
240
+ <div id="inline-file-list" class="file-list-inline"></div>
241
+
242
+ <!-- Dropzone siempre visible -->
243
+ <div id="chat-dropzone" class="chat-dropzone">
244
+ <i class="bi bi-cloud-upload"></i>
245
+ <span>Arrastra archivos aquí o haz clic para adjuntar</span>
246
+ <span id="file-counter" class="dropzone-counter" style="display:none;">0/5</span>
247
+ </div>
248
+ </div>
249
+
241
250
  </div>
242
251
 
243
252
  <!-- Incluir los modales desde un archivo externo -->
@@ -77,7 +77,7 @@ class BaseLoginView(MethodView):
77
77
  # LLM configuration: default model and availables
78
78
  default_llm_model, available_llm_models = self.config_service.get_llm_configuration(company_short_name)
79
79
 
80
- prompts = self.prompt_service.get_user_prompts(company_short_name)
80
+ prompts = self.prompt_service.get_prompts(company_short_name)
81
81
 
82
82
  # Get the entire 'js_messages' block in the correct language.
83
83
  js_translations = self.i18n_service.get_translation_block('js_messages')
@@ -0,0 +1,111 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from flask import jsonify, request
7
+ from flask.views import MethodView
8
+ from injector import inject
9
+ from iatoolkit.services.auth_service import AuthService
10
+ from iatoolkit.services.prompt_service import PromptService
11
+ from iatoolkit.services.profile_service import ProfileService
12
+ from iatoolkit.services.configuration_service import ConfigurationService
13
+ from iatoolkit.services.knowledge_base_service import KnowledgeBaseService
14
+ from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
15
+ from iatoolkit.repositories.models import PromptType, PromptCategory
16
+ import logging
17
+
18
+ class CategoriesApiView(MethodView):
19
+ """
20
+ Endpoint to retrieve all available categories and types in the system.
21
+ Useful for populating dropdowns in the frontend.
22
+ """
23
+ @inject
24
+ def __init__(self,
25
+ auth_service: AuthService,
26
+ profile_service: ProfileService,
27
+ configuration_service: ConfigurationService,
28
+ knowledge_base_service: KnowledgeBaseService,
29
+ llm_query_repo: LLMQueryRepo,
30
+ prompt_service: PromptService):
31
+ self.auth_service = auth_service
32
+ self.profile_service = profile_service
33
+ self.knowledge_base_service = knowledge_base_service
34
+ self.llm_query_repo = llm_query_repo
35
+ self.configuration_service = configuration_service
36
+ self.prompt_service = prompt_service
37
+
38
+
39
+ def get(self, company_short_name):
40
+ try:
41
+ # 1. Verify Authentication
42
+ auth_result = self.auth_service.verify()
43
+ if not auth_result.get("success"):
44
+ return jsonify(auth_result), 401
45
+
46
+ # 2. Get Company
47
+ company = self.profile_service.get_company_by_short_name(company_short_name)
48
+ if not company:
49
+ return jsonify({"error": "Company not found"}), 404
50
+
51
+ # 3. Gather Categories
52
+ response_data = {
53
+ "prompt_types": [t.value for t in PromptType],
54
+ "prompt_categories": [],
55
+ "collection_types": [],
56
+ # Future categories can be added here (e.g., tool_types, user_roles)
57
+ }
58
+
59
+ # A. Prompt Categories (from DB)
60
+ prompt_cats = self.llm_query_repo.get_all_categories(company_id=company.id)
61
+ response_data["prompt_categories"] = [c.name for c in prompt_cats]
62
+
63
+ # B. Collection Types (from KnowledgeBaseService)
64
+ response_data["collection_types"] = self.knowledge_base_service.get_collection_names(company_short_name)
65
+
66
+ # C. LLM Models (from ConfigurationService)
67
+ _, llm_models = self.configuration_service.get_llm_configuration(company_short_name)
68
+ # Extract only IDs
69
+ response_data["llm_models"] = [m['id'] for m in llm_models if 'id' in m]
70
+
71
+ return jsonify(response_data)
72
+
73
+ except Exception as e:
74
+ logging.exception(f"Error fetching categories for {company_short_name}: {e}")
75
+ return jsonify({"status": "error", "message": str(e)}), 500
76
+
77
+ def post(self, company_short_name):
78
+ try:
79
+ # 1. Verify Authentication
80
+ auth_result = self.auth_service.verify()
81
+ if not auth_result.get("success"):
82
+ return jsonify(auth_result), 401
83
+
84
+ # 2. Get Company
85
+ company = self.profile_service.get_company_by_short_name(company_short_name)
86
+ if not company:
87
+ return jsonify({"error": "Company not found"}), 404
88
+
89
+ # 3. Parse Request
90
+ data = request.get_json() or {}
91
+
92
+ # 4. Sync Collection Types
93
+ # The service expects a list of names strings
94
+ if 'collection_types' in data:
95
+ self.knowledge_base_service.sync_collection_types(
96
+ company_short_name,
97
+ data.get('collection_types', [])
98
+ )
99
+
100
+ # 5. Sync Prompt Categories
101
+ if 'prompt_categories' in data:
102
+ self.prompt_service.sync_prompt_categories(
103
+ company_short_name,
104
+ data.get('prompt_categories', [])
105
+ )
106
+
107
+ return jsonify({"status": "success", "message": "Categories synchronized successfully"}), 200
108
+
109
+ except Exception as e:
110
+ logging.exception(f"Error syncing categories for {company_short_name}: {e}")
111
+ return jsonify({"status": "error", "message": str(e)}), 500
@@ -48,7 +48,7 @@ class ChatView(MethodView):
48
48
  branding_data = self.branding_service.get_company_branding(company_short_name)
49
49
  onboarding_cards = self.config_service.get_configuration(company_short_name, 'onboarding_cards')
50
50
  default_llm_model, available_llm_models = self.config_service.get_llm_configuration(company_short_name)
51
- prompts = self.prompt_service.get_user_prompts(company_short_name)
51
+ prompts = self.prompt_service.get_prompts(company_short_name)
52
52
  js_translations = self.i18n_service.get_translation_block('js_messages')
53
53
 
54
54
  return render_template(
@@ -62,7 +62,7 @@ class ConfigurationApiView(MethodView):
62
62
  Body: { "key": "llm.model", "value": "gpt-4" }
63
63
  """
64
64
  try:
65
- auth_result = self.auth_service.verify(anonymous=False) # Require valid user for updates
65
+ auth_result = self.auth_service.verify()
66
66
  if not auth_result.get("success"):
67
67
  return jsonify(auth_result), 401
68
68
 
@@ -142,7 +142,7 @@ class FinalizeContextView(MethodView):
142
142
  )
143
143
 
144
144
  # 3. render the chat page.
145
- prompts = self.prompt_service.get_user_prompts(company_short_name)
145
+ prompts = self.prompt_service.get_prompts(company_short_name)
146
146
  onboarding_cards = self.config_service.get_configuration(company_short_name, 'onboarding_cards')
147
147
 
148
148
  # Get the entire 'js_messages' block in the correct language.
@@ -3,9 +3,11 @@
3
3
  #
4
4
  # IAToolkit is open source software.
5
5
 
6
- from flask import jsonify
6
+ from flask import jsonify, request
7
7
  from flask.views import MethodView
8
8
  from iatoolkit.services.prompt_service import PromptService
9
+ from iatoolkit.services.profile_service import ProfileService
10
+ from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
9
11
  from iatoolkit.services.auth_service import AuthService
10
12
  from injector import inject
11
13
  import logging
@@ -15,23 +17,102 @@ class PromptApiView(MethodView):
15
17
  @inject
16
18
  def __init__(self,
17
19
  auth_service: AuthService,
18
- prompt_service: PromptService ):
20
+ prompt_service: PromptService,
21
+ profile_service: ProfileService,
22
+ llm_query_repo: LLMQueryRepo):
19
23
  self.auth_service = auth_service
20
24
  self.prompt_service = prompt_service
25
+ self.profile_service = profile_service
26
+ self.llm_query_repo = llm_query_repo
21
27
 
22
- def get(self, company_short_name):
28
+ def get(self, company_short_name, prompt_name=None):
29
+ """
30
+ GET /: Lista el árbol de prompts (Categorías > Prompts).
31
+ GET /<name>: Devuelve detalle completo: metadata + contenido texto.
32
+ """
23
33
  try:
24
34
  # get access credentials
25
35
  auth_result = self.auth_service.verify(anonymous=True)
26
36
  if not auth_result.get("success"):
27
37
  return jsonify(auth_result), auth_result.get('status_code')
28
38
 
29
- response = self.prompt_service.get_user_prompts(company_short_name)
30
- if "error" in response:
31
- return {'error_message': response["error"]}, 402
39
+ company = self.profile_service.get_company_by_short_name(company_short_name)
40
+ if not company:
41
+ return jsonify({"error": "Company not found"}), 404
42
+
43
+ if prompt_name:
44
+ # get the prompt object from database
45
+ prompt_obj = self.llm_query_repo.get_prompt_by_name(company, prompt_name)
46
+ if not prompt_obj:
47
+ return jsonify({"error": "Prompt not found"}), 404
48
+
49
+ # get the prompt content
50
+ content = self.prompt_service.get_prompt_content(company, prompt_name)
51
+
52
+ return jsonify({
53
+ "meta": prompt_obj.to_dict(),
54
+ "content": content
55
+ })
56
+ else:
57
+ # Check for query param to include all prompts (admin view)
58
+ include_all = request.args.get('all', 'false').lower() == 'true'
59
+
60
+ # return prompts based on filter
61
+ return jsonify(self.prompt_service.get_prompts(company_short_name, include_all=include_all))
32
62
 
33
- return response, 200
34
63
  except Exception as e:
35
64
  logging.exception(
36
65
  f"unexpected error getting company prompts: {e}")
37
66
  return jsonify({"error_message": str(e)}), 500
67
+
68
+ def put(self, company_short_name, prompt_name):
69
+ try:
70
+ auth_result = self.auth_service.verify()
71
+ if not auth_result.get("success"):
72
+ return jsonify(auth_result), 401
73
+
74
+ data = request.get_json()
75
+
76
+ # The service handles file magic and YAML sync
77
+ self.prompt_service.save_prompt(company_short_name, prompt_name, data)
78
+
79
+ return jsonify({"status": "success"})
80
+ except Exception as e:
81
+ logging.exception(f"Error saving prompt {prompt_name}: {e}")
82
+ return jsonify({"status": "error", "message": str(e)}), 500
83
+
84
+ def post(self, company_short_name, prompt_name=None):
85
+ """Creates a new prompt."""
86
+ try:
87
+ auth_result = self.auth_service.verify()
88
+ if not auth_result.get("success"):
89
+ return jsonify(auth_result), 401
90
+
91
+ data = request.get_json()
92
+ # If prompt_name is not in URL, check body
93
+ target_name = prompt_name if prompt_name else data.get('name')
94
+
95
+ if not target_name:
96
+ return jsonify({"status": "error", "message": "Prompt name is required"}), 400
97
+
98
+ # Reuse save_prompt logic which handles create/update
99
+ self.prompt_service.save_prompt(company_short_name, target_name, data)
100
+
101
+ return jsonify({"status": "success"})
102
+ except Exception as e:
103
+ logging.exception(f"Error creating prompt: {e}")
104
+ return jsonify({"status": "error", "message": str(e)}), 500
105
+
106
+ def delete(self, company_short_name, prompt_name):
107
+ """Deletes a prompt."""
108
+ try:
109
+ auth_result = self.auth_service.verify()
110
+ if not auth_result.get("success"):
111
+ return jsonify(auth_result), 401
112
+
113
+ self.prompt_service.delete_prompt(company_short_name, prompt_name)
114
+
115
+ return jsonify({"status": "success"})
116
+ except Exception as e:
117
+ logging.exception(f"Error deleting prompt {prompt_name}: {e}")
118
+ return jsonify({"status": "error", "message": str(e)}), 500
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iatoolkit
3
- Version: 1.7.0
3
+ Version: 1.15.3
4
4
  Summary: IAToolkit
5
5
  Author: Fernando Libedinsky
6
6
  License-Expression: MIT