iatoolkit 0.7.5__py3-none-any.whl → 0.7.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of iatoolkit might be problematic. Click here for more details.

Files changed (49) hide show
  1. {iatoolkit-0.7.5.dist-info → iatoolkit-0.7.7.dist-info}/METADATA +1 -1
  2. iatoolkit-0.7.7.dist-info/RECORD +80 -0
  3. {iatoolkit-0.7.5.dist-info → iatoolkit-0.7.7.dist-info}/top_level.txt +3 -0
  4. infra/__init__.py +5 -0
  5. infra/call_service.py +140 -0
  6. infra/connectors/__init__.py +5 -0
  7. infra/connectors/file_connector.py +17 -0
  8. infra/connectors/file_connector_factory.py +57 -0
  9. infra/connectors/google_cloud_storage_connector.py +53 -0
  10. infra/connectors/google_drive_connector.py +68 -0
  11. infra/connectors/local_file_connector.py +46 -0
  12. infra/connectors/s3_connector.py +33 -0
  13. infra/gemini_adapter.py +356 -0
  14. infra/google_chat_app.py +57 -0
  15. infra/llm_client.py +430 -0
  16. infra/llm_proxy.py +139 -0
  17. infra/llm_response.py +40 -0
  18. infra/mail_app.py +145 -0
  19. infra/openai_adapter.py +90 -0
  20. infra/redis_session_manager.py +76 -0
  21. repositories/__init__.py +5 -0
  22. repositories/database_manager.py +95 -0
  23. repositories/document_repo.py +33 -0
  24. repositories/llm_query_repo.py +91 -0
  25. repositories/models.py +309 -0
  26. repositories/profile_repo.py +118 -0
  27. repositories/tasks_repo.py +52 -0
  28. repositories/vs_repo.py +139 -0
  29. views/__init__.py +5 -0
  30. views/change_password_view.py +91 -0
  31. views/chat_token_request_view.py +98 -0
  32. views/chat_view.py +51 -0
  33. views/download_file_view.py +58 -0
  34. views/external_chat_login_view.py +88 -0
  35. views/external_login_view.py +40 -0
  36. views/file_store_view.py +58 -0
  37. views/forgot_password_view.py +64 -0
  38. views/history_view.py +57 -0
  39. views/home_view.py +34 -0
  40. views/llmquery_view.py +65 -0
  41. views/login_view.py +60 -0
  42. views/prompt_view.py +37 -0
  43. views/signup_view.py +87 -0
  44. views/tasks_review_view.py +83 -0
  45. views/tasks_view.py +98 -0
  46. views/user_feedback_view.py +74 -0
  47. views/verify_user_view.py +55 -0
  48. iatoolkit-0.7.5.dist-info/RECORD +0 -36
  49. {iatoolkit-0.7.5.dist-info → iatoolkit-0.7.7.dist-info}/WHEEL +0 -0
infra/mail_app.py ADDED
@@ -0,0 +1,145 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ import sib_api_v3_sdk
7
+ from sib_api_v3_sdk.rest import ApiException
8
+ from common.exceptions import IAToolkitException
9
+ import os
10
+ import base64
11
+ import logging
12
+
13
+ MAX_ATTACH_BYTES = int(os.getenv("BREVO_MAX_ATTACH_BYTES", str(5 * 1024 * 1024))) # 5MB seguro
14
+
15
+
16
+ class MailApp:
17
+ def __init__(self,):
18
+ self.configuration = sib_api_v3_sdk.Configuration()
19
+ self.configuration.api_key['api-key'] = os.getenv('BREVO_API_KEY')
20
+ self.mail_api = sib_api_v3_sdk.TransactionalEmailsApi(sib_api_v3_sdk.ApiClient(self.configuration))
21
+ self.sender = {"email": "ia@iatoolkit.com", "name": "IA Toolkit"}
22
+
23
+ @staticmethod
24
+ def _strip_data_url_prefix(b64: str) -> str:
25
+ if not isinstance(b64, str):
26
+ return b64
27
+ i = b64.find("base64,")
28
+ return b64[i + 7:] if i != -1 else b64
29
+
30
+ def _normalize_attachments(self, attachments: list[dict] | None):
31
+ if not attachments:
32
+ return None
33
+ sdk_attachments = []
34
+ for idx, a in enumerate(attachments, start=1):
35
+ # 1) claves obligatorias
36
+ if "filename" not in a:
37
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
38
+ f"Adjunto #{idx} inválido: falta 'filename'")
39
+ if "content" not in a:
40
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
41
+ f"Adjunto '{a.get('filename', '(sin nombre)')}' inválido: falta 'content'")
42
+
43
+ name = a["filename"]
44
+ content_b64 = a["content"]
45
+
46
+ # 2) quitar prefijo data URL si vino así
47
+ content_b64 = self._strip_data_url_prefix(content_b64)
48
+
49
+ # 3) validar base64 (y que no esté vacío)
50
+ try:
51
+ raw = base64.b64decode(content_b64, validate=True)
52
+ except Exception:
53
+ logging.error("Adjunto '%s' con base64 inválido (primeros 16 chars: %r)",
54
+ name, str(content_b64)[:16])
55
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
56
+ f"Adjunto '{name}' trae base64 inválido")
57
+
58
+ if not raw:
59
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
60
+ f"Adjunto '{name}' está vacío")
61
+
62
+ # 4) volver a base64 limpio (sin prefijos, sin espacios)
63
+ clean_b64 = base64.b64encode(raw).decode("utf-8")
64
+
65
+ # 5) construir objeto del SDK
66
+ sdk_attachments.append(
67
+ sib_api_v3_sdk.SendSmtpEmailAttachment(
68
+ name=name,
69
+ content=clean_b64
70
+ )
71
+ )
72
+ return sdk_attachments
73
+
74
+
75
+ def send_email(self,
76
+ to: str,
77
+ subject: str,
78
+ body: str,
79
+ sender: dict = None,
80
+ attachments: list[dict] = None):
81
+ if not sender:
82
+ sender = self.sender
83
+
84
+ try:
85
+ sdk_attachments = self._normalize_attachments(attachments)
86
+ email = sib_api_v3_sdk.SendSmtpEmail(
87
+ to=[{"email": to}],
88
+ sender=sender,
89
+ subject=subject,
90
+ html_content=body,
91
+ attachment=sdk_attachments
92
+ )
93
+ api_response = self.mail_api.send_transac_email(email)
94
+
95
+ # Validación de respuesta
96
+ message_id = getattr(api_response, "message_id", None) or getattr(api_response, "messageId", None)
97
+ message_ids = getattr(api_response, "message_ids", None) or getattr(api_response, "messageIds", None)
98
+ if not ((isinstance(message_id, str) and message_id.strip()) or
99
+ (isinstance(message_ids, (list, tuple)) and len(message_ids) > 0)):
100
+ logging.error("MAIL ERROR: Respuesta sin message_id(s): %r", api_response)
101
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
102
+ "Brevo no retornó message_id; el envío podría haber fallado.")
103
+
104
+ return api_response
105
+
106
+ except ApiException as e:
107
+ logging.exception("MAIL ERROR (ApiException): status=%s reason=%s body=%s",
108
+ getattr(e, "status", None), getattr(e, "reason", None), getattr(e, "body", None))
109
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
110
+ f"Error Brevo (status={getattr(e, 'status', 'N/A')}): {getattr(e, 'reason', str(e))}") from e
111
+ except Exception as e:
112
+ logging.exception("MAIL ERROR: %s", str(e))
113
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
114
+ f"No se pudo enviar correo: {str(e)}") from e
115
+ ''''
116
+ def send_template_email(self,
117
+ subject: str,
118
+ recipients: list,
119
+ template_name: str,
120
+ context: dict,
121
+ sender=None):
122
+ try:
123
+ # Renderiza el template con el contexto proporcionado
124
+ with self.app.app_context():
125
+ html_message = render_template(template_name, **context)
126
+
127
+ # Crea el mensaje
128
+ msg = Message(
129
+ subject=subject,
130
+ recipients=recipients,
131
+ html=html_message,
132
+ sender=sender or self.app.config.get('MAIL_DEFAULT_SENDER')
133
+ )
134
+
135
+ # Envía el correo
136
+ # self.send_brevo_email(msg)
137
+ pass
138
+ except jinja2.exceptions.TemplateNotFound:
139
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
140
+ f"Error: No se encontró el template '{template_name}'.")
141
+ except Exception as e:
142
+ raise IAToolkitException(IAToolkitException.ErrorType.MAIL_ERROR,
143
+ f'No se pudo enviar correo: {str(e)}') from e
144
+
145
+ '''
@@ -0,0 +1,90 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ import logging
7
+ from typing import Dict, List, Optional
8
+ from infra.llm_response import LLMResponse, ToolCall, Usage
9
+ from common.exceptions import IAToolkitException
10
+
11
+
12
+ class OpenAIAdapter:
13
+ """Adaptador para la API de OpenAI"""
14
+
15
+ def __init__(self, openai_client):
16
+ self.client = openai_client
17
+
18
+ def create_response(self,
19
+ model: str,
20
+ input: List[Dict],
21
+ previous_response_id: Optional[str] = None,
22
+ context_history: Optional[List[Dict]] = None,
23
+ tools: Optional[List[Dict]] = None,
24
+ text: Optional[Dict] = None,
25
+ reasoning: Optional[Dict] = None,
26
+ tool_choice: str = "auto") -> LLMResponse:
27
+ """Llamada a la API de OpenAI y mapeo a estructura común"""
28
+ try:
29
+ # Preparar parámetros para OpenAI
30
+ params = {
31
+ 'model': model,
32
+ 'input': input
33
+ }
34
+
35
+ if previous_response_id:
36
+ params['previous_response_id'] = previous_response_id
37
+ if tools:
38
+ params['tools'] = tools
39
+ if text:
40
+ params['text'] = text
41
+ if reasoning:
42
+ params['reasoning'] = reasoning
43
+ if tool_choice != "auto":
44
+ params['tool_choice'] = tool_choice
45
+
46
+ # Llamar a la API de OpenAI
47
+ openai_response = self.client.responses.create(**params)
48
+
49
+ # Mapear la respuesta a estructura común
50
+ return self._map_openai_response(openai_response)
51
+
52
+ except Exception as e:
53
+ error_message = f"Error calling OpenAI API: {str(e)}"
54
+ logging.error(error_message)
55
+
56
+ # En caso de error de contexto
57
+ if "context_length_exceeded" in str(e):
58
+ error_message = 'Tu consulta supera el limite de contexto, sale e ingresa de nuevo a IAToolkit'
59
+
60
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
61
+
62
+ def _map_openai_response(self, openai_response) -> LLMResponse:
63
+ """Mapear respuesta de OpenAI a estructura común"""
64
+ # Mapear tool calls
65
+ tool_calls = []
66
+ if hasattr(openai_response, 'output') and openai_response.output:
67
+ for tool_call in openai_response.output:
68
+ if hasattr(tool_call, 'type') and tool_call.type == "function_call":
69
+ tool_calls.append(ToolCall(
70
+ call_id=getattr(tool_call, 'call_id', ''),
71
+ type=tool_call.type,
72
+ name=getattr(tool_call, 'name', ''),
73
+ arguments=getattr(tool_call, 'arguments', '{}')
74
+ ))
75
+
76
+ # Mapear usage
77
+ usage = Usage(
78
+ input_tokens=openai_response.usage.input_tokens if openai_response.usage else 0,
79
+ output_tokens=openai_response.usage.output_tokens if openai_response.usage else 0,
80
+ total_tokens=openai_response.usage.total_tokens if openai_response.usage else 0
81
+ )
82
+
83
+ return LLMResponse(
84
+ id=openai_response.id,
85
+ model=openai_response.model,
86
+ status=openai_response.status,
87
+ output_text=getattr(openai_response, 'output_text', ''),
88
+ output=tool_calls,
89
+ usage=usage
90
+ )
@@ -0,0 +1,76 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ import logging
7
+ import os
8
+ import redis
9
+ import json
10
+ from urllib.parse import urlparse
11
+
12
+
13
+ class RedisSessionManager:
14
+ """
15
+ SessionManager que usa Redis directamente para datos persistentes como llm_history.
16
+ Separado de Flask session para tener control total sobre el ciclo de vida de los datos.
17
+ """
18
+ _client = None
19
+
20
+ @classmethod
21
+ def _get_client(cls):
22
+ if cls._client is None:
23
+ # Usar exactamente los mismos parámetros que Flask-Session
24
+ url = urlparse(os.environ.get("REDIS_URL"))
25
+ cls._client = redis.Redis(
26
+ host=url.hostname,
27
+ port=url.port,
28
+ password=url.password,
29
+ ssl=(url.scheme == "rediss"),
30
+ ssl_cert_reqs=None,
31
+ decode_responses=True # Importante para strings
32
+ )
33
+ # verify connection
34
+ cls._client.ping()
35
+ info = cls._client.info(section="server")
36
+ db = cls._client.connection_pool.connection_kwargs.get('db', 0)
37
+ return cls._client
38
+
39
+ @classmethod
40
+ def set(cls, key: str, value: str, ex: int = None):
41
+ client = cls._get_client()
42
+ result = client.set(key, value, ex=ex)
43
+ return result
44
+
45
+ @classmethod
46
+ def get(cls, key: str, default: str = ""):
47
+ client = cls._get_client()
48
+ value = client.get(key)
49
+ result = value if value is not None else default
50
+ return result
51
+
52
+ @classmethod
53
+ def remove(cls, key: str):
54
+ client = cls._get_client()
55
+ result = client.delete(key)
56
+ return result
57
+
58
+ @classmethod
59
+ def set_json(cls, key: str, value: dict, ex: int = None):
60
+ json_str = json.dumps(value)
61
+ return cls.set(key, json_str, ex=ex)
62
+
63
+ @classmethod
64
+ def get_json(cls, key: str, default: dict = None):
65
+ if default is None:
66
+ default = {}
67
+
68
+ json_str = cls.get(key, "")
69
+ if not json_str:
70
+ return default
71
+
72
+ try:
73
+ return json.loads(json_str)
74
+ except json.JSONDecodeError:
75
+ logging.warning(f"[RedisSessionManager] Invalid JSON in key '{key}': {json_str}")
76
+ return default
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
@@ -0,0 +1,95 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ # database_manager.py
7
+ from sqlalchemy import create_engine, event, inspect
8
+ from sqlalchemy.orm import sessionmaker, scoped_session
9
+ from sqlalchemy.engine.url import make_url
10
+ from repositories.models import Base
11
+ from injector import inject
12
+ from pgvector.psycopg2 import register_vector
13
+
14
+
15
+ class DatabaseManager:
16
+ @inject
17
+ def __init__(self, database_url: str, register_pgvector: bool = True):
18
+ """
19
+ Inicializa el gestor de la base de datos.
20
+ :param database_url: URL de la base de datos.
21
+ :param echo: Si True, habilita logs de SQL.
22
+ """
23
+ self.url = make_url(database_url)
24
+ self._engine = create_engine(database_url, echo=False)
25
+ self.SessionFactory = sessionmaker(bind=self._engine)
26
+ self.scoped_session = scoped_session(self.SessionFactory)
27
+
28
+ # REGISTRAR pgvector para cada nueva conexión solo en postgres
29
+ if register_pgvector and self.url.get_backend_name() == 'postgresql':
30
+ event.listen(self._engine, 'connect', self.on_connect)
31
+
32
+ @staticmethod
33
+ def on_connect(dbapi_connection, connection_record):
34
+ """
35
+ Esta función se ejecuta cada vez que se establece una conexión.
36
+ dbapi_connection es la conexión psycopg2 real.
37
+ """
38
+ register_vector(dbapi_connection)
39
+
40
+ def get_session(self):
41
+ return self.scoped_session()
42
+
43
+ def get_connection(self):
44
+ return self._engine.connect()
45
+
46
+ def get_engine(self):
47
+ return self._engine
48
+
49
+ def create_all(self):
50
+ Base.metadata.create_all(self._engine)
51
+
52
+ def drop_all(self):
53
+ Base.metadata.drop_all(self._engine)
54
+
55
+ def remove_session(self):
56
+ self.scoped_session.remove()
57
+
58
+ def get_table_schema(self,
59
+ table_name: str,
60
+ schema_name: str | None = None,
61
+ exclude_columns: list[str] | None = None) -> str:
62
+ inspector = inspect(self._engine)
63
+
64
+ if table_name not in inspector.get_table_names():
65
+ raise RuntimeError(f"La tabla '{table_name}' no existe en la BD.")
66
+
67
+ if exclude_columns is None:
68
+ exclude_columns = []
69
+
70
+ # get all thre table columns
71
+ columns = inspector.get_columns(table_name)
72
+
73
+ # construct a json dictionary with the table definition
74
+ json_dict = {
75
+ "table": table_name,
76
+ "description": f"Definición de la tabla {table_name}.",
77
+ "fields": []
78
+ }
79
+ if schema_name:
80
+ json_dict["description"] += f"Los detalles de cada campo están en el objeto **`{schema_name}`**."
81
+
82
+ # now add every column to the json dictionary
83
+ for col in columns:
84
+ name = col["name"]
85
+
86
+ # omit the excluded columns.
87
+ if name in exclude_columns:
88
+ continue
89
+
90
+ json_dict["fields"].append({
91
+ "name": name,
92
+ "type": str(col["type"]),
93
+ })
94
+
95
+ return "\n\n" + str(json_dict)
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from repositories.models import Document
7
+ from injector import inject
8
+ from repositories.database_manager import DatabaseManager
9
+ from common.exceptions import IAToolkitException
10
+
11
+
12
+ class DocumentRepo:
13
+ @inject
14
+ def __init__(self, db_manager: DatabaseManager):
15
+ self.session = db_manager.get_session()
16
+
17
+ def insert(self,new_document: Document):
18
+ self.session.add(new_document)
19
+ self.session.commit()
20
+ return new_document
21
+
22
+ def get(self, company_id, filename: str ) -> Document:
23
+ if not company_id or not filename:
24
+ raise IAToolkitException(IAToolkitException.ErrorType.PARAM_NOT_FILLED,
25
+ 'Falta empresa o filename')
26
+
27
+ return self.session.query(Document).filter_by(company_id=company_id, filename=filename).first()
28
+
29
+ def get_by_id(self, document_id: int) -> Document:
30
+ if not document_id:
31
+ return None
32
+
33
+ return self.session.query(Document).filter_by(id=document_id).first()
@@ -0,0 +1,91 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from repositories.models import LLMQuery, Function, Company, Prompt, PromptCategory
7
+ from injector import inject
8
+ from repositories.database_manager import DatabaseManager
9
+ from sqlalchemy import or_
10
+
11
+ class LLMQueryRepo:
12
+ @inject
13
+ def __init__(self, db_manager: DatabaseManager):
14
+ self.session = db_manager.get_session()
15
+
16
+ def add_query(self, query: LLMQuery):
17
+ self.session.add(query)
18
+ self.session.commit()
19
+ return query
20
+
21
+
22
+ def get_company_functions(self, company: Company) -> list[Function]:
23
+ return (
24
+ self.session.query(Function)
25
+ .filter(
26
+ Function.is_active.is_(True),
27
+ or_(
28
+ Function.company_id == company.id,
29
+ Function.system_function.is_(True)
30
+ )
31
+ )
32
+ .all()
33
+ )
34
+
35
+ def create_or_update_function(self, new_function: Function):
36
+ function = self.session.query(Function).filter_by(company_id=new_function.company_id,
37
+ name=new_function.name).first()
38
+ if function:
39
+ function.description = new_function.description
40
+ function.parameters = new_function.parameters
41
+ function.system_function = new_function.system_function
42
+ else:
43
+ self.session.add(new_function)
44
+ function = new_function
45
+
46
+ self.session.commit()
47
+ return function
48
+
49
+ def create_or_update_prompt(self, new_prompt: Prompt):
50
+ prompt = self.session.query(Prompt).filter_by(company_id=new_prompt.company_id,
51
+ name=new_prompt.name).first()
52
+ if prompt:
53
+ prompt.category_id = new_prompt.category_id
54
+ prompt.description = new_prompt.description
55
+ prompt.order = new_prompt.order
56
+ prompt.active = new_prompt.active
57
+ prompt.is_system_prompt = new_prompt.is_system_prompt
58
+ prompt.filename = new_prompt.filename
59
+ prompt.custom_fields = new_prompt.custom_fields
60
+ else:
61
+ self.session.add(new_prompt)
62
+ prompt = new_prompt
63
+
64
+ self.session.commit()
65
+ return prompt
66
+
67
+ def create_or_update_prompt_category(self, new_category: PromptCategory):
68
+ category = self.session.query(PromptCategory).filter_by(company_id=new_category.company_id,
69
+ name=new_category.name).first()
70
+ if category:
71
+ category.order = new_category.order
72
+ else:
73
+ self.session.add(new_category)
74
+ category = new_category
75
+
76
+ self.session.commit()
77
+ return category
78
+
79
+ def get_history(self, company: Company, user_identifier: str) -> list[LLMQuery]:
80
+ return self.session.query(LLMQuery).filter(
81
+ LLMQuery.user_identifier == user_identifier,
82
+ ).filter_by(company_id=company.id).order_by(LLMQuery.created_at.desc()).limit(100).all()
83
+
84
+ def get_prompts(self, company: Company) -> list[Prompt]:
85
+ return self.session.query(Prompt).filter_by(company_id=company.id, is_system_prompt=False).all()
86
+
87
+ def get_system_prompts(self) -> list[Prompt]:
88
+ return self.session.query(Prompt).filter_by(is_system_prompt=True, active=True).order_by(Prompt.order).all()
89
+
90
+ def get_prompt_by_name(self, company: Company, prompt_name: str):
91
+ return self.session.query(Prompt).filter_by(company_id=company.id, name=prompt_name).first()