feat: Implement main application shell with navigation and color scheme toggle
- Added Appshell component with responsive navbar and main content area - Integrated ColorSchemeToggle for light/dark mode switching - Created Welcome component with styled title and introductory text - Developed ChatPage for LLM interaction with WebSocket support - Implemented Biblioteca for managing notes with rich text editor - Added LoginPage for user authentication with error handling - Introduced MessageList and MessageBubble components for chat messages - Styled components with CSS modules for consistent design
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
|
||||
class OpenAICredencial:
|
||||
def __init__(self, titulo: str, api_key: str, organizacion: str = None, id: str = None):
|
||||
"""
|
||||
:param titulo: Nombre descriptivo para esta credencial.
|
||||
:param api_key: Clave secreta de la API de OpenAI.
|
||||
:param organizacion: (Opcional) ID de la organización asociada a la cuenta de OpenAI.
|
||||
"""
|
||||
self.id = id if id is not None else GeneradorIDUnico("OPAK").generar()
|
||||
self.titulo = titulo
|
||||
self.api_key = api_key
|
||||
self.organizacion = organizacion
|
||||
|
||||
def get_headers(self) -> dict:
|
||||
"""
|
||||
Retorna los encabezados necesarios para autenticar una petición HTTP a OpenAI.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
if self.organizacion:
|
||||
headers["OpenAI-Organization"] = self.organizacion
|
||||
return headers
|
||||
@@ -0,0 +1,107 @@
|
||||
import os
|
||||
import base64
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.base import Base
|
||||
from domains.ApiKeys.openai_apikey import OpenAICredencial
|
||||
from domains.Security.Encriptar import Encriptar_fernet
|
||||
from entrypoint import ENV_PATH
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
|
||||
|
||||
from sqlalchemy import Column, String
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
|
||||
|
||||
# ----------------------
|
||||
# Cargar clave maestra
|
||||
# ----------------------
|
||||
load_dotenv(ENV_PATH)
|
||||
pssword = os.getenv('MASTER_PASSWORD')
|
||||
if pssword is None:
|
||||
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class OpenAICredencialModel(Base, Model_base):
|
||||
__tablename__ = 'openai_credenciales'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
titulo = Column(String, nullable=False)
|
||||
api_key = Column(String, nullable=False) # Encriptada como base64 string
|
||||
organizacion = Column(String, nullable=True)
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class OpenAICredencialMapper(Mapper_base[OpenAICredencial, OpenAICredencialModel]):
|
||||
|
||||
@staticmethod
|
||||
def to_model(obj: OpenAICredencial) -> OpenAICredencialModel:
|
||||
return OpenAICredencialModel(
|
||||
id=obj.id,
|
||||
titulo=obj.titulo,
|
||||
api_key=base64.b64encode(
|
||||
Encriptar_fernet.encriptar(obj.api_key, pssword)
|
||||
).decode("utf-8"),
|
||||
organizacion=obj.organizacion
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: OpenAICredencialModel) -> OpenAICredencial:
|
||||
return OpenAICredencial(
|
||||
id=model.id,
|
||||
titulo=model.titulo,
|
||||
api_key=Encriptar_fernet.desencriptar(
|
||||
base64.b64decode(model.api_key), pssword
|
||||
),
|
||||
organizacion=model.organizacion
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: OpenAICredencial) -> dict:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"titulo": obj.titulo,
|
||||
"api_key": base64.b64encode(
|
||||
Encriptar_fernet.encriptar(obj.api_key, pssword)
|
||||
).decode("utf-8"),
|
||||
"organizacion": obj.organizacion
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> OpenAICredencial:
|
||||
return OpenAICredencial(
|
||||
id=data["id"],
|
||||
titulo=data["titulo"],
|
||||
api_key=Encriptar_fernet.desencriptar(
|
||||
base64.b64decode(data["api_key"]), pssword
|
||||
),
|
||||
organizacion=data.get("organizacion")
|
||||
)
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class OpenAICredencialRepo(Repo_base[OpenAICredencialModel, OpenAICredencial]):
|
||||
def __init__(self, conexion: ConexionBase):
|
||||
super().__init__(
|
||||
session=conexion.get_session(),
|
||||
modelo=OpenAICredencialModel,
|
||||
mapper=OpenAICredencialMapper
|
||||
)
|
||||
|
||||
def get_by_titulo(self, titulo: str) -> OpenAICredencial | None:
|
||||
model = (
|
||||
self.session.query(self.Modelo)
|
||||
.filter_by(titulo=titulo, sys_deleted_at=None)
|
||||
.first()
|
||||
)
|
||||
return self.Mapper.from_model(model) if model else None
|
||||
@@ -0,0 +1,75 @@
|
||||
# src\ArquitectureLayer\Mapper.py
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar, Generic, Type
|
||||
import json
|
||||
|
||||
TDominio = TypeVar("TDominio")
|
||||
TModelo = TypeVar("TModelo")
|
||||
|
||||
class Mapper_base(ABC, Generic[TDominio, TModelo]):
|
||||
# ----------------------------
|
||||
# Conversiones individuales
|
||||
# ----------------------------
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def to_model(obj: TDominio) -> TModelo:
|
||||
"""Convierte objeto de dominio a modelo ORM"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def from_model(model: TModelo) -> TDominio:
|
||||
"""Convierte modelo ORM a objeto de dominio"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def to_dict(obj: TDominio) -> dict:
|
||||
"""Convierte objeto de dominio a diccionario plano"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def from_dict(d: dict) -> TDominio:
|
||||
"""Convierte diccionario plano a objeto de dominio"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def to_json(cls, obj: TDominio) -> str:
|
||||
"""Convierte objeto de dominio a JSON string"""
|
||||
return json.dumps(cls.to_dict(obj), default=str)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> TDominio:
|
||||
"""Convierte JSON string a objeto de dominio"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
# ----------------------------
|
||||
# Conversiones en lote (bulk)
|
||||
# ----------------------------
|
||||
|
||||
@classmethod
|
||||
def to_model_list(cls, objs: list[TDominio]) -> list[TModelo]:
|
||||
return [cls.to_model(o) for o in objs]
|
||||
|
||||
@classmethod
|
||||
def from_model_list(cls, models: list[TModelo]) -> list[TDominio]:
|
||||
return [cls.from_model(m) for m in models]
|
||||
|
||||
@classmethod
|
||||
def to_dict_list(cls, objs: list[TDominio]) -> list[dict]:
|
||||
return [cls.to_dict(o) for o in objs]
|
||||
|
||||
@classmethod
|
||||
def from_dict_list(cls, dicts: list[dict]) -> list[TDominio]:
|
||||
return [cls.from_dict(d) for d in dicts]
|
||||
|
||||
@classmethod
|
||||
def to_json_list(cls, objs: list[TDominio]) -> str:
|
||||
return json.dumps(cls.to_dict_list(objs), default=str)
|
||||
|
||||
@classmethod
|
||||
def from_json_list(cls, json_str: str) -> list[TDominio]:
|
||||
return cls.from_dict_list(json.loads(json_str))
|
||||
@@ -0,0 +1,63 @@
|
||||
# src\ArquitectureLayer\Model.py
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, Integer, Text, func
|
||||
from sqlalchemy.ext.declarative import declared_attr, as_declarative
|
||||
from datetime import datetime
|
||||
|
||||
@as_declarative()
|
||||
class Model_base:
|
||||
__abstract__ = True
|
||||
|
||||
@declared_attr
|
||||
def sys_created_at(cls):
|
||||
return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
@declared_attr
|
||||
def sys_created_by(cls):
|
||||
return Column(String, nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def sys_updated_at(cls):
|
||||
return Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def sys_updated_by(cls):
|
||||
return Column(String, nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def sys_version(cls):
|
||||
return Column(Integer, default=1, nullable=False)
|
||||
|
||||
@declared_attr
|
||||
def sys_notes(cls):
|
||||
return Column(Text, nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def sys_deleted_at(cls):
|
||||
return Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
id_val = getattr(self, "id", None)
|
||||
return f"<{self.__class__.__name__} id={id_val}>"
|
||||
|
||||
def __str__(self):
|
||||
cls = self.__class__.__name__
|
||||
id_val = getattr(self, "id", None)
|
||||
return f"{cls}(id={id_val})"
|
||||
|
||||
def __json__(self) -> dict:
|
||||
"""Devuelve una representación JSON serializable (dict plano)."""
|
||||
out = {}
|
||||
|
||||
# Prevención de error: solo ejecuta si __table__ existe
|
||||
if not hasattr(self, "__table__"):
|
||||
return out
|
||||
|
||||
for attr in self.__table__.columns:
|
||||
val = getattr(self, attr.name, None)
|
||||
if isinstance(val, datetime):
|
||||
out[attr.name] = val.isoformat()
|
||||
else:
|
||||
out[attr.name] = val
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,148 @@
|
||||
# src\ArquitectureLayer\Repo.py
|
||||
|
||||
from abc import ABC
|
||||
from typing import Type, TypeVar, Generic, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base # Asegúrate de importar tu ABC base
|
||||
|
||||
TModelo = TypeVar("TModelo")
|
||||
TDominio = TypeVar("TDominio")
|
||||
|
||||
class Repo_base(ABC, Generic[TModelo, TDominio]):
|
||||
def __init__(self, session: Session, modelo: type[TModelo], mapper: type[Mapper_base[TDominio, TModelo]]):
|
||||
self.session = session
|
||||
self.Modelo = modelo
|
||||
self.Mapper = mapper
|
||||
|
||||
# ----------------------
|
||||
# ADD
|
||||
# ----------------------
|
||||
|
||||
def add(self, dominio: TDominio, created_by: Optional[str] = None, notes: Optional[str] = None) -> str:
|
||||
data = self.Mapper.to_dict(dominio)
|
||||
data.update({
|
||||
"sys_created_by": created_by,
|
||||
"sys_notes": notes,
|
||||
"sys_version": 1
|
||||
})
|
||||
model = self.Modelo(**data)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return model.id
|
||||
|
||||
def add_many(self, dominios: list[TDominio], created_by: Optional[str] = None, notes: Optional[str] = None) -> list[str]:
|
||||
ids = []
|
||||
for dominio in dominios:
|
||||
data = self.Mapper.to_dict(dominio)
|
||||
data.update({
|
||||
"sys_created_by": created_by,
|
||||
"sys_notes": notes,
|
||||
"sys_version": 1
|
||||
})
|
||||
model = self.Modelo(**data)
|
||||
self.session.add(model)
|
||||
ids.append(model.id)
|
||||
self.session.commit()
|
||||
return ids
|
||||
|
||||
# ----------------------
|
||||
# GET
|
||||
# ----------------------
|
||||
|
||||
def get_by_id(self, id_: str) -> Optional[TDominio]:
|
||||
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
|
||||
return self.Mapper.from_model(model) if model else None
|
||||
|
||||
def get_all(self) -> list[TDominio]:
|
||||
models = self.session.query(self.Modelo).filter_by(sys_deleted_at=None).all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
def get_paginated(self, offset: int = 0, limit: int = 10) -> list[TDominio]:
|
||||
models = self.session.query(self.Modelo).filter_by(sys_deleted_at=None).offset(offset).limit(limit).all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
def get_deleted(self) -> list[TDominio]:
|
||||
models = self.session.query(self.Modelo).filter(self.Modelo.sys_deleted_at.isnot(None)).all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
# ----------------------
|
||||
# UPDATE
|
||||
# ----------------------
|
||||
|
||||
def update(self, id_: str, new_data: dict, updated_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
|
||||
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
|
||||
if not model:
|
||||
return False
|
||||
|
||||
for key, value in new_data.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
model.sys_updated_by = updated_by
|
||||
model.sys_notes = notes
|
||||
model.sys_version = (model.sys_version or 1) + 1
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def bulk_update(self, updates: list[tuple[str, dict]], updated_by: Optional[str] = None, notes: Optional[str] = None) -> int:
|
||||
count = 0
|
||||
for id_, data in updates:
|
||||
if self.update(id_, data, updated_by=updated_by, notes=notes):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
# ----------------------
|
||||
# DELETE
|
||||
# ----------------------
|
||||
|
||||
def delete_by_id(self, id_: str) -> bool:
|
||||
model = self.session.query(self.Modelo).filter_by(id=id_).first()
|
||||
if model:
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_all(self) -> int:
|
||||
deleted = self.session.query(self.Modelo).delete()
|
||||
self.session.commit()
|
||||
return deleted
|
||||
|
||||
# ----------------------
|
||||
# SOFT DELETE
|
||||
# ----------------------
|
||||
|
||||
def soft_delete(self, id_: str, deleted_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
|
||||
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
|
||||
if model:
|
||||
model.sys_deleted_at = datetime.now()
|
||||
model.sys_updated_by = deleted_by
|
||||
model.sys_notes = notes
|
||||
model.sys_version = (model.sys_version or 1) + 1
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def soft_restore(self, id_: str, restored_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
|
||||
model = self.session.query(self.Modelo).filter_by(id=id_).first()
|
||||
if model and model.sys_deleted_at is not None:
|
||||
model.sys_deleted_at = None
|
||||
model.sys_updated_by = restored_by
|
||||
model.sys_notes = notes
|
||||
model.sys_version = (model.sys_version or 1) + 1
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
# ----------------------
|
||||
# OTROS
|
||||
# ----------------------
|
||||
|
||||
def exists(self, id_: str) -> bool:
|
||||
return self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first() is not None
|
||||
|
||||
def count(self) -> int:
|
||||
return self.session.query(self.Modelo).filter_by(sys_deleted_at=None).count()
|
||||
@@ -0,0 +1,62 @@
|
||||
import requests
|
||||
from domains.Credenciales.ollama_credencial import OllamaCredencial
|
||||
|
||||
class OllamaCliente:
|
||||
def __init__(self, credencial: OllamaCredencial):
|
||||
"""
|
||||
Inicializa el cliente de Ollama con una instancia de OllamaCredencial.
|
||||
"""
|
||||
self.credencial = credencial
|
||||
self.base_url = self.credencial.base_url
|
||||
|
||||
# --- Chat Completions ---
|
||||
def chat_completion(self, model: str, messages: list, stream: bool = False, **kwargs):
|
||||
url = f"{self.base_url}/api/chat"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
**kwargs
|
||||
}
|
||||
response = requests.post(url, json=payload, stream=stream)
|
||||
response.raise_for_status()
|
||||
|
||||
return self._handle_stream(response) if stream else response.json()
|
||||
|
||||
def _handle_stream(self, response):
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
parsed = line.decode("utf-8")
|
||||
# Extraer contenido si está en JSON como {"message":{"content":"..."},...}
|
||||
if parsed.startswith("{"):
|
||||
import json
|
||||
data = json.loads(parsed)
|
||||
if "message" in data and "content" in data["message"]:
|
||||
yield data["message"]["content"]
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# --- Text Completion (legacy) ---
|
||||
def completion(self, model: str, prompt: str, **kwargs):
|
||||
url = f"{self.base_url}/api/generate"
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
**kwargs
|
||||
}
|
||||
response = requests.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# --- Embeddings ---
|
||||
def embedding(self, model: str, input: str | list[str], **kwargs):
|
||||
url = f"{self.base_url}/api/embeddings"
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": input,
|
||||
**kwargs
|
||||
}
|
||||
response = requests.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -0,0 +1,70 @@
|
||||
from openai import OpenAI
|
||||
from domains.ApiKeys.openai_apikey import OpenAICredencial
|
||||
|
||||
class OpenAICliente:
|
||||
def __init__(self, credencial: OpenAICredencial):
|
||||
"""
|
||||
Inicializa el cliente de OpenAI con una instancia de OpenAICredencial.
|
||||
"""
|
||||
self.credencial = credencial
|
||||
self.client = OpenAI(api_key=self.credencial.api_key)
|
||||
if self.credencial.organizacion:
|
||||
self.client.organization = self.credencial.organizacion
|
||||
|
||||
# --- Chat Completions ---
|
||||
def chat_completion(self, model: str, messages: list, stream: bool = False, **kwargs):
|
||||
response = self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=stream, # Parámetro explícito
|
||||
**kwargs
|
||||
)
|
||||
return self._handle_stream(response, stream) if stream else response
|
||||
|
||||
def _handle_stream(self, stream, _):
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
# --- Text Completions (legacy) ---
|
||||
def completion(self, model: str, prompt: str, **kwargs):
|
||||
return self.client.completions.create(model=model, prompt=prompt, **kwargs)
|
||||
|
||||
# --- Text Edits ---
|
||||
def edit(self, model: str, input: str, instruction: str, **kwargs):
|
||||
return self.client.edits.create(model=model, input=input, instruction=instruction, **kwargs)
|
||||
|
||||
# --- Embeddings ---
|
||||
def embedding(self, model: str, input: str | list[str], **kwargs):
|
||||
return self.client.embeddings.create(model=model, input=input, **kwargs)
|
||||
|
||||
# --- Moderation ---
|
||||
def moderation(self, input: str | list[str], model="text-moderation-latest", **kwargs):
|
||||
return self.client.moderations.create(input=input, model=model, **kwargs)
|
||||
|
||||
# --- Image Generation ---
|
||||
def image_generate(self, prompt: str, **kwargs):
|
||||
return self.client.images.generate(prompt=prompt, **kwargs)
|
||||
|
||||
# --- Audio Transcription ---
|
||||
def audio_transcribe(self, model: str, file_path: str, **kwargs):
|
||||
with open(file_path, "rb") as f:
|
||||
return self.client.audio.transcriptions.create(model=model, file=f, **kwargs)
|
||||
|
||||
# --- Audio Translation ---
|
||||
def audio_translate(self, model: str, file_path: str, **kwargs):
|
||||
with open(file_path, "rb") as f:
|
||||
return self.client.audio.translations.create(model=model, file=f, **kwargs)
|
||||
|
||||
# --- File Upload ---
|
||||
def file_upload(self, file_path: str, purpose="fine-tune", **kwargs):
|
||||
with open(file_path, "rb") as f:
|
||||
return self.client.files.create(file=f, purpose=purpose, **kwargs)
|
||||
|
||||
# --- File List ---
|
||||
def file_list(self, **kwargs):
|
||||
return self.client.files.list(**kwargs)
|
||||
|
||||
# --- File Delete ---
|
||||
def file_delete(self, file_id: str, **kwargs):
|
||||
return self.client.files.delete(file_id=file_id, **kwargs)
|
||||
@@ -0,0 +1,39 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
class ConexionBase(ABC):
|
||||
def __init__(self, uri: str):
|
||||
self.estado = "pendiente"
|
||||
self.timestamp = datetime.now(timezone.utc)
|
||||
self._engine: Engine = create_engine(uri)
|
||||
self._Session = sessionmaker(bind=self._engine)
|
||||
self._session_instance: Session | None = None
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self) -> Session:
|
||||
if self._session_instance is None:
|
||||
self._session_instance = self._Session()
|
||||
return self._session_instance
|
||||
|
||||
@abstractmethod
|
||||
def get_engine(self) -> Engine:
|
||||
return self._engine
|
||||
|
||||
def probar_conexion(self) -> bool:
|
||||
try:
|
||||
with self._engine.connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
self.estado = "exito"
|
||||
return True
|
||||
except SQLAlchemyError:
|
||||
self.estado = "fallo"
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self._session_instance is not None:
|
||||
self._session_instance.close()
|
||||
self._session_instance = None
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.Credenciales.postgres_credencial import PostgresCredencial
|
||||
|
||||
class PostgresConexion(ConexionBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.estado = "pendiente"
|
||||
self.timestamp = datetime.now(timezone.utc)
|
||||
|
||||
if args and isinstance(args[0], PostgresCredencial):
|
||||
credencial = args[0]
|
||||
self.host = credencial.host
|
||||
self.port = credencial.port
|
||||
self.dbname = credencial.dbname
|
||||
self.user = credencial.user
|
||||
self.password = credencial.password
|
||||
uri = credencial.get_uri()
|
||||
else:
|
||||
self.user = kwargs.get("user")
|
||||
self.password = kwargs.get("password")
|
||||
self.host = kwargs.get("host")
|
||||
self.port = kwargs.get("port", 5432)
|
||||
self.dbname = kwargs.get("db") or kwargs.get("dbname")
|
||||
uri = f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}"
|
||||
|
||||
self._engine: Engine = create_engine(uri)
|
||||
self._Session = sessionmaker(bind=self._engine)
|
||||
|
||||
# ✅ INICIALIZAR LA SESIÓN AQUÍ
|
||||
self._session_instance: Session | None = None
|
||||
|
||||
def get_session(self) -> Session:
|
||||
if self._session_instance is None:
|
||||
self._session_instance = self._Session()
|
||||
return self._session_instance
|
||||
|
||||
def get_engine(self) -> Engine:
|
||||
return self._engine
|
||||
|
||||
def probar_conexion(self) -> bool:
|
||||
try:
|
||||
with self._engine.connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
self.estado = "exito"
|
||||
return True
|
||||
except SQLAlchemyError:
|
||||
self.estado = "fallo"
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self._session_instance is not None:
|
||||
self._session_instance.close()
|
||||
self._session_instance = None
|
||||
@@ -0,0 +1,20 @@
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
|
||||
class OllamaCredencial:
|
||||
def __init__(self, titulo: str, base_url: str = "http://localhost:11434", id: str = None):
|
||||
"""
|
||||
:param titulo: Nombre descriptivo para esta credencial de Ollama.
|
||||
:param base_url: URL base donde está corriendo el servidor de Ollama (por defecto: localhost).
|
||||
"""
|
||||
self.id = id if id is not None else GeneradorIDUnico("OLLA").generar()
|
||||
self.titulo = titulo
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def get_headers(self) -> dict:
|
||||
"""
|
||||
Retorna encabezados para autenticación si se requiere en el futuro.
|
||||
Por defecto, Ollama local no usa headers especiales.
|
||||
"""
|
||||
return {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
|
||||
class PostgresCredencial:
|
||||
def __init__(self, titulo: str, host: str, port: int, dbname: str, user: str, password: str, id: str = None):
|
||||
self.id = id if id is not None else GeneradorIDUnico("PGCR").generar()
|
||||
self.titulo = titulo
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.dbname = dbname
|
||||
self.user = user
|
||||
self.password = password
|
||||
|
||||
|
||||
def get_uri(self) -> str:
|
||||
"""
|
||||
Retorna una URI de conexión para PostgreSQL.
|
||||
"""
|
||||
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}"
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import DateTime, Text, func
|
||||
import base64
|
||||
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.base import Base
|
||||
from domains.Credenciales.postgres_credencial import PostgresCredencial
|
||||
from domains.Security.Encriptar import Encriptar_fernet
|
||||
|
||||
# ----------------------
|
||||
# Cargar clave maestra
|
||||
# ----------------------
|
||||
from entrypoint import ENV_PATH
|
||||
load_dotenv(ENV_PATH)
|
||||
pssword = os.getenv('MASTER_PASSWORD')
|
||||
if pssword is None:
|
||||
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class PostgresCredencialModel(Base, Model_base):
|
||||
__tablename__ = 'postgres_credenciales'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
titulo = Column(String, nullable=False)
|
||||
host = Column(String, nullable=False)
|
||||
port = Column(Integer, nullable=False)
|
||||
dbname = Column(String, nullable=False)
|
||||
user = Column(String, nullable=False)
|
||||
password = Column(String, nullable=False) # Encriptada como base64 string
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class PostgresCredencialMapper(Mapper_base[PostgresCredencial, PostgresCredencialModel]):
|
||||
|
||||
@staticmethod
|
||||
def to_model(obj: PostgresCredencial) -> PostgresCredencialModel:
|
||||
return PostgresCredencialModel(
|
||||
id=obj.id,
|
||||
titulo=obj.titulo,
|
||||
host=obj.host,
|
||||
port=obj.port,
|
||||
dbname=obj.dbname,
|
||||
user=obj.user,
|
||||
password=base64.b64encode(
|
||||
Encriptar_fernet.encriptar(obj.password, pssword)
|
||||
).decode('utf-8')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: PostgresCredencialModel) -> PostgresCredencial:
|
||||
return PostgresCredencial(
|
||||
id=model.id,
|
||||
titulo=model.titulo,
|
||||
host=model.host,
|
||||
port=model.port,
|
||||
dbname=model.dbname,
|
||||
user=model.user,
|
||||
password=Encriptar_fernet.desencriptar(
|
||||
base64.b64decode(model.password), pssword
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: PostgresCredencial) -> dict:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"titulo": obj.titulo,
|
||||
"host": obj.host,
|
||||
"port": obj.port,
|
||||
"dbname": obj.dbname,
|
||||
"user": obj.user,
|
||||
"password": base64.b64encode(
|
||||
Encriptar_fernet.encriptar(obj.password, pssword)
|
||||
).decode('utf-8')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> PostgresCredencial:
|
||||
return PostgresCredencial(
|
||||
id=data["id"],
|
||||
titulo=data["titulo"],
|
||||
host=data["host"],
|
||||
port=data["port"],
|
||||
dbname=data["dbname"],
|
||||
user=data["user"],
|
||||
password=Encriptar_fernet.desencriptar(
|
||||
base64.b64decode(data["password"]), pssword
|
||||
)
|
||||
)
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class PostgresCredencialRepo(Repo_base[PostgresCredencialModel, PostgresCredencial]):
|
||||
def __init__(self, conexion: ConexionBase):
|
||||
super().__init__(
|
||||
session=conexion.get_session(),
|
||||
modelo=PostgresCredencialModel,
|
||||
mapper=PostgresCredencialMapper
|
||||
)
|
||||
|
||||
def get_by_titulo(self, titulo: str) -> PostgresCredencial | None:
|
||||
model = (
|
||||
self.session.query(self.Modelo)
|
||||
.filter_by(titulo=titulo, sys_deleted_at=None)
|
||||
.first()
|
||||
)
|
||||
return self.Mapper.from_model(model) if model else None
|
||||
@@ -0,0 +1,443 @@
|
||||
from domains.Llms.Modelos.Base_model import ModeloABC
|
||||
from domains.Llms.Memory.Base_MemoryConv import MemoryConvABC
|
||||
from domains.Llms.MCPs.McpClient_Registry import ClientRegistry
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Union, AsyncGenerator
|
||||
import re
|
||||
import json
|
||||
|
||||
from entrypoint.init_db import db_credencial
|
||||
from domains.Logger.logger_db import LoggerDB, logger
|
||||
LoggerDB(db_credencial, "logger_agentes", created_by="sistema")
|
||||
|
||||
|
||||
|
||||
|
||||
class AgenteAI:
|
||||
def __init__(
|
||||
self,
|
||||
modelo: ModeloABC,
|
||||
nombre: str,
|
||||
descripcion: str,
|
||||
system_prompt: str,
|
||||
rol: str,
|
||||
objetivos: List[str],
|
||||
max_iterations: int = 1,
|
||||
memoria: Optional[MemoryConvABC] = None,
|
||||
version: str = "1.0.0",
|
||||
mcp: Optional[ClientRegistry] = None,
|
||||
output_schema: Optional[dict] = None,
|
||||
):
|
||||
self.modelo = modelo
|
||||
self.memoria = memoria
|
||||
self.output_schema = output_schema
|
||||
|
||||
self.nombre = nombre
|
||||
self.descripcion = descripcion
|
||||
self.system_prompt = system_prompt
|
||||
self.max_iterations = max_iterations
|
||||
self.rol = rol
|
||||
self.objetivos = objetivos
|
||||
self.version = version
|
||||
|
||||
self.created_at = datetime.now()
|
||||
self.updated_at = self.created_at
|
||||
self.numero_interacciones = 0
|
||||
self.mcp = mcp # <-- Aquí guardamos el registry
|
||||
|
||||
|
||||
|
||||
def actualizar_configuracion(self, **kwargs):
|
||||
for clave, valor in kwargs.items():
|
||||
if hasattr(self, clave):
|
||||
setattr(self, clave, valor)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
|
||||
|
||||
|
||||
async def generar_system_prompt(self) -> str:
|
||||
|
||||
info = f"""Eres un agente de texto y te llamas {self.nombre}
|
||||
|
||||
### Descripción:
|
||||
{self.descripcion}
|
||||
|
||||
### Rol:
|
||||
{self.rol}
|
||||
|
||||
### Objetivos:
|
||||
{chr(10).join(f"- {o}" for o in self.objetivos)}
|
||||
|
||||
### System Prompt:
|
||||
{self.system_prompt}
|
||||
|
||||
Siempre estructura tus respuestas con claridad, y termina con <END> cuando hayas completado la tarea principal del usuario.
|
||||
""".strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def construir_prompt_usuario(self, prompt_usuario: str) -> str:
|
||||
bloques = []
|
||||
|
||||
if self.mcp:
|
||||
tools_str = await self._obtener_herramientas_disponibles_str()
|
||||
bloques.append(f"### Herramientas disponibles (MCP):\n{tools_str}")
|
||||
bloques.append("""### Instrucciones para actuar con herramientas MCP:
|
||||
Eres un agente conversacional con acceso a herramientas MCP. Cuando el usuario te haga una solicitud, sigue este proceso paso a paso:
|
||||
---
|
||||
🧠 **Piensa**:
|
||||
Reflexiona en voz alta. Explica claramente qué crees que se necesita hacer y por qué.
|
||||
🎯 **Decide**:
|
||||
Elige si puedes resolverlo tú solo, si necesitas más información del usuario, o si una herramienta MCP sería útil.
|
||||
⚙️ **Actúa**:
|
||||
Si decides usar una herramienta, **escribe el bloque MCP justo después**, sin ningún texto extra después del bloque.
|
||||
---
|
||||
### Formato MCP:
|
||||
|
||||
```mcp
|
||||
{
|
||||
"server": "tools",
|
||||
"tool": "get_current_user",
|
||||
"input": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❗ REGLAS IMPORTANTES:
|
||||
|
||||
- **Puedes pensar y decidir con texto normal**, pero:
|
||||
- El **bloque MCP debe ser lo último** que aparece en tu mensaje.
|
||||
- **NO escribas nada después del bloque MCP.**
|
||||
- Solo usa `<END>` cuando:
|
||||
- hayas terminado completamente la tarea del usuario,
|
||||
- e interpretado la salida de las herramientas que usaste.
|
||||
- Puedes hacer múltiples pasos si es necesario: usar una herramienta, esperar su salida, analizarla, usar otra, etc.
|
||||
- Si decides no usar herramientas, simplemente responde como lo harías normalmente.
|
||||
- Si no estás seguro de algo, **pide aclaraciones al usuario** antes de actuar.
|
||||
|
||||
---
|
||||
|
||||
✅ Correcto:
|
||||
```mcp
|
||||
{
|
||||
"server": "tools",
|
||||
"tool": "generate_uuid",
|
||||
"input": {}
|
||||
}
|
||||
````
|
||||
🔵 Siempre usa ` ```mcp ` (con triple backtick y la palabra `mcp`) antes del JSON. No escribas nada después del bloque.
|
||||
````
|
||||
---
|
||||
|
||||
### ✅ Ejemplo correcto:
|
||||
|
||||
Necesito generar un identificador único para el usuario.
|
||||
Para eso usaré la herramienta `generate_uuid` disponible.
|
||||
|
||||
```mcp
|
||||
{
|
||||
"server": "tools",
|
||||
"tool": "generate_uuid",
|
||||
"input": {}
|
||||
}
|
||||
|
||||
""")
|
||||
|
||||
if self.memoria:
|
||||
historial = self.memoria.cargar_historial_chat()
|
||||
if historial:
|
||||
memoria_str = "\n".join(
|
||||
[f"{msg['role']}: {msg['content']}" for msg in historial]
|
||||
)
|
||||
bloques.append(f"### Memoria del chat:\n{memoria_str}")
|
||||
|
||||
if self.output_schema:
|
||||
schema_str = str(self.output_schema)
|
||||
bloques.append(f"### Salida esperada:\n{schema_str}")
|
||||
|
||||
bloques.append(f"### Prompt del usuario:\n{prompt_usuario}")
|
||||
|
||||
return "\n\n".join(bloques)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Conseguir las herramientas disponibles
|
||||
|
||||
async def _obtener_herramientas_disponibles_str(self) -> str:
|
||||
logger.info("Inicio de obtención de herramientas disponibles")
|
||||
|
||||
if not self.mcp:
|
||||
logger.warning("No se ha definido el cliente MCP.")
|
||||
return "No se han definido herramientas disponibles."
|
||||
|
||||
try:
|
||||
resultado = await self.mcp.listar_tools_por_cliente()
|
||||
tools_por_cliente = resultado.get("tools", {})
|
||||
errores = resultado.get("errores", {})
|
||||
|
||||
logger.debug(f"Tools obtenidas: {list(tools_por_cliente.keys())}")
|
||||
logger.debug(f"Errores detectados: {list(errores.keys())}")
|
||||
|
||||
herramientas = []
|
||||
|
||||
for name, tools in tools_por_cliente.items():
|
||||
if not tools:
|
||||
logger.info(f"Servidor {name} no tiene herramientas disponibles.")
|
||||
continue
|
||||
|
||||
herramientas.append(f"\n🔌 Server: {name}")
|
||||
for tool in tools:
|
||||
props = tool.inputSchema.get("properties", {})
|
||||
parametros = "\n ".join(f"- {k} ({v.get('type', '?')})" for k, v in props.items())
|
||||
herramientas.append(f"""Nombre: {tool.name}
|
||||
Descripción: {tool.description}
|
||||
Parámetros:
|
||||
{parametros}
|
||||
""")
|
||||
logger.debug(f"Herramienta agregada: {tool.name} del servidor {name}")
|
||||
|
||||
if errores:
|
||||
herramientas.append("\n⚠️ Los siguientes servidores no están disponibles:")
|
||||
for name, error in errores.items():
|
||||
herramientas.append(f"- {name}: {error}")
|
||||
logger.warning(f"Servidor con error: {name} -> {error}")
|
||||
|
||||
logger.info("Finalización de obtención de herramientas exitosamente.")
|
||||
return "\n".join(herramientas) or "No hay herramientas disponibles actualmente."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error inesperado al obtener herramientas: {str(e)}", exc_info=True)
|
||||
return "Se produjo un error al obtener las herramientas disponibles."
|
||||
|
||||
|
||||
|
||||
|
||||
### Formatear prompt para agentes
|
||||
|
||||
def _formatear_prompt(self, mensajes: List[dict]) -> str:
|
||||
return "\n".join([f"{msg['role']}: {msg['content']}" for msg in mensajes])
|
||||
|
||||
|
||||
|
||||
### Ejecutar codigo MCP
|
||||
|
||||
async def ejecutar_bloque_mcp(self, respuesta: str) -> Optional[str]:
|
||||
logger.info("Iniciando ejecución de bloque MCP.")
|
||||
|
||||
patron = r"```mcp\s*(\{.*?\})\s*```"
|
||||
match = re.search(patron, respuesta, re.DOTALL)
|
||||
|
||||
if not match:
|
||||
patron_incorrecto = r"```[\s]*\{.*?\}[\s]*```"
|
||||
if re.search(patron_incorrecto, respuesta, re.DOTALL):
|
||||
logger.warning("Bloque detectado sin especificador `mcp`.")
|
||||
return "Advertencia: Usaste un bloque de herramienta MCP pero olvidaste indicar el lenguaje `mcp`. Corrige el bloque a: ```mcp { ... } ```"
|
||||
logger.info("No se encontró ningún bloque MCP en la respuesta.")
|
||||
return None
|
||||
|
||||
try:
|
||||
bloque_json_str = match.group(1)
|
||||
logger.debug(f"Bloque MCP detectado: {bloque_json_str}")
|
||||
|
||||
bloque = json.loads(bloque_json_str)
|
||||
|
||||
server_name = bloque["server"]
|
||||
tool_name = bloque["tool"]
|
||||
input_args = bloque.get("input", {})
|
||||
|
||||
logger.info(f"Bloque MCP válido. Servidor: {server_name}, Herramienta: {tool_name}")
|
||||
logger.debug(f"Parámetros de entrada: {input_args}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error al interpretar el bloque MCP: {e}", exc_info=True)
|
||||
return f"Error al interpretar el bloque MCP: {e}"
|
||||
|
||||
try:
|
||||
cliente_mcp = self.mcp.get(server_name)
|
||||
except KeyError:
|
||||
logger.warning(f"No se encontró el cliente MCP para el servidor '{server_name}'.")
|
||||
return f"No se encontró el cliente MCP para el servidor '{server_name}'"
|
||||
|
||||
try:
|
||||
logger.info(f"Ejecutando herramienta '{tool_name}' en servidor '{server_name}' con argumentos: {json.dumps(input_args, ensure_ascii=False)}")
|
||||
|
||||
async with cliente_mcp:
|
||||
resultado = await cliente_mcp.call_tool(tool_name, input_args)
|
||||
logger.info(f"Ejecución completada exitosamente. Resultado: {resultado}")
|
||||
return str(resultado)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error al ejecutar herramienta '{tool_name}' en servidor '{server_name}': {e}", exc_info=True)
|
||||
return f"Error al ejecutar herramienta '{tool_name}' en servidor '{server_name}': {e}"
|
||||
|
||||
|
||||
|
||||
### Ejecutar VARIOS bloques MCP
|
||||
|
||||
async def ejecutar_multiples_bloques_mcp(self, respuesta: str) -> Optional[List[str]]:
|
||||
logger.info("Buscando múltiples bloques MCP en la respuesta.")
|
||||
|
||||
patron = r"```mcp\s*(\{.*?\})\s*```"
|
||||
matches = re.finditer(patron, respuesta, re.DOTALL)
|
||||
|
||||
resultados = []
|
||||
hubo_bloques = False
|
||||
|
||||
for match in matches:
|
||||
hubo_bloques = True
|
||||
bloque_json_str = match.group(1)
|
||||
try:
|
||||
bloque = json.loads(bloque_json_str)
|
||||
server_name = bloque["server"]
|
||||
tool_name = bloque["tool"]
|
||||
input_args = bloque.get("input", {})
|
||||
|
||||
logger.info(f"Ejecutando bloque MCP: servidor={server_name}, herramienta={tool_name}")
|
||||
|
||||
try:
|
||||
cliente_mcp = self.mcp.get(server_name)
|
||||
except KeyError:
|
||||
msg = f"No se encontró el cliente MCP para el servidor '{server_name}'"
|
||||
logger.warning(msg)
|
||||
resultados.append(msg)
|
||||
continue
|
||||
|
||||
async with cliente_mcp:
|
||||
resultado = await cliente_mcp.call_tool(tool_name, input_args)
|
||||
resultado_str = f"[{server_name}.{tool_name}] → {resultado}"
|
||||
resultados.append(resultado_str)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error al procesar bloque MCP: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
resultados.append(error_msg)
|
||||
|
||||
if not hubo_bloques:
|
||||
logger.info("No se encontró ningún bloque MCP en la respuesta.")
|
||||
return None
|
||||
|
||||
return resultados
|
||||
|
||||
|
||||
|
||||
|
||||
###----------- Funcion para interactuar
|
||||
|
||||
async def interactuar(self, prompt: str, stream: bool = False) -> Union[str, AsyncGenerator[str, None]]:
|
||||
mensaje_usuario = await self.construir_prompt_usuario(prompt)
|
||||
contexto = [{"role": "user", "content": mensaje_usuario}]
|
||||
prompt_final = self._formatear_prompt(contexto)
|
||||
|
||||
respuesta = await self.modelo.responder(
|
||||
prompt=prompt_final,
|
||||
system_prompt=await self.generar_system_prompt(),
|
||||
stream=stream
|
||||
)
|
||||
|
||||
return respuesta
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
###----------- Funcion para interactuar en bucle
|
||||
|
||||
async def interactuar_en_bucle(self, prompt: str, stream: bool = False) -> Union[List[str], AsyncGenerator[str, None]]:
|
||||
respuestas = [] if not stream else None
|
||||
respuesta_anterior = ""
|
||||
resultado_mcp_anterior = None # <-- Guarda último resultado del MCP
|
||||
iteration = 0
|
||||
prompt_original = prompt.strip()
|
||||
|
||||
async def generador():
|
||||
nonlocal iteration, respuesta_anterior, resultado_mcp_anterior
|
||||
|
||||
while self.max_iterations == 0 or iteration < self.max_iterations:
|
||||
instruccion_fin = (
|
||||
"\n\nIMPORTANTE: Cuando hayas respondido completamente a la pregunta original del usuario y no requieras más pasos, "
|
||||
"escribe <END> para indicar que has terminado."
|
||||
)
|
||||
|
||||
if iteration == 0:
|
||||
prompt_actual = prompt_original + instruccion_fin
|
||||
else:
|
||||
prompt_actual = (
|
||||
f"Esta es la pregunta original:\n{prompt_original}\n\n"
|
||||
f"Esto fue lo último que dijiste:\n{respuesta_anterior}\n\n"
|
||||
f"{instruccion_fin}"
|
||||
)
|
||||
|
||||
if resultado_mcp_anterior:
|
||||
prompt_actual += (
|
||||
"\n\nEsta fue la salida de la herramienta que usaste:\n"
|
||||
f"{resultado_mcp_anterior}\n\n"
|
||||
"Úsala para seguir resolviendo el problema o tomar una nueva decisión."
|
||||
)
|
||||
|
||||
mensaje_usuario = await self.construir_prompt_usuario(prompt_actual)
|
||||
contexto = [{"role": "user", "content": mensaje_usuario}]
|
||||
prompt_final = self._formatear_prompt(contexto)
|
||||
|
||||
respuesta = await self.modelo.responder(
|
||||
prompt=prompt_final,
|
||||
system_prompt=await self.generar_system_prompt(),
|
||||
stream=stream
|
||||
)
|
||||
|
||||
if stream:
|
||||
buffer_respuesta = ""
|
||||
async for token in respuesta:
|
||||
buffer_respuesta += token
|
||||
yield token
|
||||
respuesta_anterior = buffer_respuesta
|
||||
else:
|
||||
respuestas.append(respuesta)
|
||||
respuesta_anterior = respuesta
|
||||
|
||||
# Revisar y ejecutar bloque MCP si existe
|
||||
resultado_mcp_anterior = None
|
||||
if "```mcp" in respuesta_anterior:
|
||||
resultados_mcp = await self.ejecutar_multiples_bloques_mcp(respuesta_anterior)
|
||||
if resultados_mcp:
|
||||
resultado_mcp_anterior = "\n".join(resultados_mcp)
|
||||
|
||||
if stream:
|
||||
yield "\n" + resultado_mcp_anterior
|
||||
else:
|
||||
respuestas.append(resultado_mcp_anterior)
|
||||
|
||||
# Guardar historial si hay memoria
|
||||
if self.memoria:
|
||||
self.memoria.guardar_turno("user", prompt_actual)
|
||||
self.memoria.guardar_turno("assistant", respuesta_anterior)
|
||||
|
||||
self.numero_interacciones += 1
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
if "<end>" in respuesta_anterior.lower() and "```mcp" not in respuesta_anterior.lower():
|
||||
break
|
||||
|
||||
iteration += 1
|
||||
|
||||
return generador() if stream else await generador_to_list(generador())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Helper para consumir generador asincrónico si no es stream
|
||||
async def generador_to_list(gen: AsyncGenerator[str, None]) -> List[str]:
|
||||
buffer = ""
|
||||
async for chunk in gen:
|
||||
buffer += chunk
|
||||
return [buffer]
|
||||
@@ -0,0 +1,13 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
class EmbedderABC(ABC):
|
||||
@abstractmethod
|
||||
def encoder(self, text: str) -> List[float]:
|
||||
"""Genera los embeddings para un texto dado."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def dimension_number(self) -> int:
|
||||
"""Devuelve la dimensión del modelo de embedding."""
|
||||
pass
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import List
|
||||
from domains.Llms.Embedders.Base_Embedder import EmbedderABC # Asegúrate de que EmbedderABC esté en este módulo
|
||||
from domains.ApiKeys.openai_apikey import OpenAICredencial
|
||||
from domains.ConexionApis.OpenAi_conexion import OpenAICliente
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
|
||||
class OpenAIEmbedder(EmbedderABC):
|
||||
def __init__(self, credencial: OpenAICredencial,
|
||||
model: str,
|
||||
id: str = None):
|
||||
self.model = model
|
||||
self.client = OpenAICliente(credencial)
|
||||
self._dimension = None # Lazy loading
|
||||
self.id = id if id is not None else GeneradorIDUnico("OAMB").generar()
|
||||
|
||||
def encoder(self, text: str) -> List[float]:
|
||||
"""
|
||||
Genera los embeddings para un texto dado utilizando el modelo de OpenAI.
|
||||
"""
|
||||
response = self.client.embedding(model=self.model, input=text)
|
||||
embedding = response.data[0].embedding
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
return embedding
|
||||
|
||||
def dimension_number(self) -> int:
|
||||
"""
|
||||
Devuelve la dimensión del modelo de embedding, generando un embedding si no se ha calculado aún.
|
||||
"""
|
||||
if self._dimension is None:
|
||||
_ = self.encoder("dimension_check")
|
||||
return self._dimension
|
||||
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Column, String
|
||||
from sqlalchemy import Column, String, ForeignKey
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.base import Base
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
from domains.Llms.Embedders.Openai_embedder import OpenAIEmbedder
|
||||
from domains.ApiKeys.openai_apikey import OpenAICredencial
|
||||
|
||||
# ----------------------
|
||||
# Cargar configuración desde .env si se requiere
|
||||
# ----------------------
|
||||
from entrypoint import ENV_PATH
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class OpenAIEmbedderModel(Base, Model_base):
|
||||
__tablename__ = "openai_embedders"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
api_key_id = Column(String, ForeignKey("openai_credenciales.id"), nullable=False)
|
||||
model = Column(String, nullable=False)
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class OpenAIEmbedderMapper(Mapper_base[OpenAIEmbedder, OpenAIEmbedderModel]):
|
||||
|
||||
@staticmethod
|
||||
def to_model(obj: OpenAIEmbedder) -> OpenAIEmbedderModel:
|
||||
return OpenAIEmbedderModel(
|
||||
id=obj.id,
|
||||
api_key_id=obj.client.credencial.id,
|
||||
model=obj.model
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: OpenAIEmbedderModel, credencial: OpenAICredencial) -> OpenAIEmbedder:
|
||||
return OpenAIEmbedder(
|
||||
id=model.id,
|
||||
credencial=credencial,
|
||||
model=model.model
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: OpenAIEmbedder) -> dict:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"api_key_id": obj.client.credencial.id,
|
||||
"model": obj.model
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict, credencial: OpenAICredencial) -> OpenAIEmbedder:
|
||||
return OpenAIEmbedder(
|
||||
id=data["id"],
|
||||
credencial=credencial,
|
||||
model=data["model"]
|
||||
)
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class OpenAIEmbedderRepo(Repo_base[OpenAIEmbedderModel, OpenAIEmbedder]):
|
||||
def __init__(self, conexion: ConexionBase):
|
||||
super().__init__(
|
||||
session=conexion.get_session(),
|
||||
modelo=OpenAIEmbedderModel,
|
||||
mapper=OpenAIEmbedderMapper
|
||||
)
|
||||
|
||||
def get_by_id(self, id_: str, credencial: OpenAICredencial) -> OpenAIEmbedder | None:
|
||||
model = self.session.get(self.Modelo, id_)
|
||||
return self.Mapper.from_model(model, credencial) if model else None
|
||||
|
||||
def get_all(self, credencial_loader: callable) -> list[OpenAIEmbedder]:
|
||||
"""
|
||||
:param credencial_loader: función que recibe un api_key_id y devuelve una instancia de OpenAICredencial
|
||||
"""
|
||||
models = self.session.query(self.Modelo).all()
|
||||
return [
|
||||
self.Mapper.from_model(m, credencial_loader(m.api_key_id))
|
||||
for m in models
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
from pydantic import AnyUrl
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import (
|
||||
StreamableHttpTransport,
|
||||
PythonStdioTransport,
|
||||
ClientTransport,
|
||||
)
|
||||
from mcp.types import *
|
||||
from fastmcp.exceptions import ClientError
|
||||
import asyncio
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self, name: str, client: Client):
|
||||
self.name = name
|
||||
self.client = client
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ClientWrapper(name={self.name})>"
|
||||
|
||||
@classmethod
|
||||
def from_http(cls, name: str, url: str | AnyUrl) -> "MCPClient":
|
||||
transport = StreamableHttpTransport(url=str(url))
|
||||
client = Client(transport=transport)
|
||||
return cls(name=name, client=client)
|
||||
|
||||
@classmethod
|
||||
def from_stdio(
|
||||
cls,
|
||||
name: str,
|
||||
script_path: Union[str, Path],
|
||||
args: Optional[list[str]] = None,
|
||||
cwd: Optional[Union[str, Path]] = None,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
) -> "MCPClient":
|
||||
transport = PythonStdioTransport(
|
||||
script_path=script_path, args=args, cwd=cwd, env=env
|
||||
)
|
||||
client = Client(transport=transport)
|
||||
return cls(name=name, client=client)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self.client.is_connected()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.client.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
# Delegación MCP
|
||||
|
||||
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> list[TextContent | ImageContent | EmbeddedResource]:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self.client.call_tool(name, arguments), timeout=10
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(f"Timeout al ejecutar herramienta '{name}'")
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, str] | None = None
|
||||
) -> GetPromptResult:
|
||||
return await self.client.get_prompt(name, arguments)
|
||||
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
return await self.client.list_tools()
|
||||
|
||||
async def list_prompts(self) -> list[Prompt]:
|
||||
return await self.client.list_prompts()
|
||||
|
||||
async def list_resources(self) -> list[Resource]:
|
||||
return await self.client.list_resources()
|
||||
|
||||
async def list_resource_templates(self) -> list[ResourceTemplate]:
|
||||
return await self.client.list_resource_templates()
|
||||
|
||||
async def read_resource(
|
||||
self, uri: AnyUrl | str
|
||||
) -> list[TextResourceContents | BlobResourceContents]:
|
||||
return await self.client.read_resource(uri)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
ref: ResourceReference | PromptReference,
|
||||
argument: dict[str, str],
|
||||
) -> Completion:
|
||||
return await self.client.complete(ref, argument)
|
||||
|
||||
async def ping(self) -> bool:
|
||||
return await self.client.ping()
|
||||
|
||||
async def set_logging_level(self, level: LoggingLevel) -> None:
|
||||
return await self.client.set_logging_level(level)
|
||||
|
||||
async def send_roots_list_changed(self) -> None:
|
||||
return await self.client.send_roots_list_changed()
|
||||
@@ -0,0 +1,56 @@
|
||||
from domains.Llms.MCPs.McpClient import MCPClient
|
||||
from typing import Any
|
||||
|
||||
class ClientRegistry:
|
||||
def __init__(self):
|
||||
self._clients: dict[str, MCPClient] = {}
|
||||
|
||||
def add(self, name: str, wrapper: MCPClient) -> None:
|
||||
self._clients[name] = wrapper
|
||||
|
||||
def get(self, name: str) -> MCPClient:
|
||||
if name not in self._clients:
|
||||
raise KeyError(f"Cliente '{name}' no encontrado en el registro.")
|
||||
return self._clients[name]
|
||||
|
||||
def all(self) -> dict[str, MCPClient]:
|
||||
return self._clients
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
return list(self._clients.keys())
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._clients
|
||||
|
||||
async def listar_tools_por_cliente(self) -> dict[str, Any]:
|
||||
resultado = {"tools": {}, "errores": {}}
|
||||
for name, wrapper in self._clients.items():
|
||||
try:
|
||||
async with wrapper:
|
||||
resultado["tools"][name] = await wrapper.list_tools()
|
||||
except Exception as e:
|
||||
resultado["errores"][name] = str(e)
|
||||
resultado["tools"][name] = []
|
||||
return resultado
|
||||
|
||||
async def listar_prompts_por_cliente(self) -> dict[str, Any]:
|
||||
resultado = {"prompts": {}, "errores": {}}
|
||||
for name, wrapper in self._clients.items():
|
||||
try:
|
||||
async with wrapper:
|
||||
resultado["prompts"][name] = await wrapper.list_prompts()
|
||||
except Exception as e:
|
||||
resultado["errores"][name] = str(e)
|
||||
resultado["prompts"][name] = []
|
||||
return resultado
|
||||
|
||||
async def listar_resources_por_cliente(self) -> dict[str, Any]:
|
||||
resultado = {"resources": {}, "errores": {}}
|
||||
for name, wrapper in self._clients.items():
|
||||
try:
|
||||
async with wrapper:
|
||||
resultado["resources"][name] = await wrapper.list_resources()
|
||||
except Exception as e:
|
||||
resultado["errores"][name] = str(e)
|
||||
resultado["resources"][name] = []
|
||||
return resultado
|
||||
@@ -0,0 +1,48 @@
|
||||
# server_runner.py
|
||||
import subprocess
|
||||
import asyncio
|
||||
import socket
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
async def wait_for_port(host: str, port: int, timeout: float = 10.0):
|
||||
for _ in range(int(timeout * 10)):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=0.5):
|
||||
return True
|
||||
except (OSError, ConnectionRefusedError):
|
||||
await asyncio.sleep(0.1)
|
||||
raise TimeoutError(f"No se pudo conectar al servidor en {host}:{port}")
|
||||
|
||||
class MCPServerRunner:
|
||||
def __init__(self, server_script_path: str, python_path: str = "python"):
|
||||
self.server_script_path = server_script_path
|
||||
self.python_path = python_path
|
||||
self.port: int = self._extraer_puerto()
|
||||
self.process: subprocess.Popen | None = None
|
||||
|
||||
def _extraer_puerto(self) -> int:
|
||||
contenido = Path(self.server_script_path).read_text(encoding="utf-8")
|
||||
coincidencias = re.findall(r"port\s*=\s*(\d+)", contenido)
|
||||
if not coincidencias:
|
||||
raise ValueError(f"No se pudo detectar el puerto en {self.server_script_path}")
|
||||
return int(coincidencias[0])
|
||||
|
||||
async def start(self):
|
||||
if self.process is None or self.process.poll() is not None:
|
||||
self.process = subprocess.Popen(
|
||||
[self.python_path, self.server_script_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
await wait_for_port("127.0.0.1", self.port)
|
||||
print(f"🟢 Servidor MCP iniciado en puerto {self.port}")
|
||||
|
||||
async def stop(self):
|
||||
if self.process and self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
print("🔴 Servidor MCP detenido")
|
||||
@@ -0,0 +1,133 @@
|
||||
from fastmcp import FastMCP
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
# Directorio base seguro
|
||||
SANDBOX_DIR = Path("./sandbox").resolve()
|
||||
SANDBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def safe_path(requested_path: str) -> Path:
|
||||
"""Siempre interpreta la ruta como relativa al SANDBOX_DIR, incluso si empieza con '/'."""
|
||||
# Normaliza la ruta quitando el primer '/'
|
||||
normalized = requested_path.strip().lstrip("/")
|
||||
full_path = (SANDBOX_DIR / normalized).resolve()
|
||||
|
||||
if not full_path.is_relative_to(SANDBOX_DIR):
|
||||
raise ValueError("Ruta fuera del directorio permitido.")
|
||||
return full_path
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(description="Lee y devuelve el contenido de un archivo de texto ubicado en el sistema de archivos seguro. El archivo debe estar dentro del sandbox.")
|
||||
def read_file(path: str) -> str:
|
||||
try:
|
||||
file_path = safe_path(path)
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"Archivo '{path}' no encontrado.")
|
||||
return file_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
return f"⚠️ Error al leer archivo '{path}': {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool(description="Escribe contenido de texto en un archivo dentro del sandbox. Si el archivo ya existe, será sobrescrito.")
|
||||
def write_file(path: str, content: str) -> str:
|
||||
file_path = safe_path(path)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
return "Archivo guardado correctamente."
|
||||
|
||||
@mcp.tool(description="Elimina de forma segura un archivo ubicado dentro del sandbox.")
|
||||
def delete_file(path: str) -> str:
|
||||
file_path = safe_path(path)
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError("Archivo no encontrado.")
|
||||
file_path.unlink()
|
||||
return "Archivo eliminado."
|
||||
|
||||
@mcp.tool(description="Crea una carpeta (y sus carpetas padre si es necesario) dentro del sandbox.")
|
||||
def create_folder(path: str) -> str:
|
||||
folder_path = safe_path(path)
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
return "Carpeta creada."
|
||||
|
||||
|
||||
|
||||
@mcp.tool(description="Lista archivos y carpetas dentro de una ruta del sandbox.")
|
||||
def list_directory(path: str = ".") -> list[str]:
|
||||
folder = safe_path(path)
|
||||
if not folder.is_dir():
|
||||
raise NotADirectoryError("Ruta no corresponde a una carpeta.")
|
||||
return sorted(str(p.relative_to(SANDBOX_DIR)) for p in folder.iterdir())
|
||||
|
||||
@mcp.tool(description="Muestra la estructura de carpetas y archivos como un árbol, desde una ruta dentro del sandbox.")
|
||||
def tree(path: str = ".", depth: int = 3) -> str:
|
||||
base = safe_path(path)
|
||||
if not base.is_dir():
|
||||
raise NotADirectoryError("Ruta no corresponde a una carpeta.")
|
||||
|
||||
tree_output = []
|
||||
|
||||
def walk(dir_path: Path, prefix: str = "", level: int = 0):
|
||||
if level > depth:
|
||||
return
|
||||
entries = sorted(dir_path.iterdir())
|
||||
for i, entry in enumerate(entries):
|
||||
connector = "└── " if i == len(entries) - 1 else "├── "
|
||||
tree_output.append(f"{prefix}{connector}{entry.name}")
|
||||
if entry.is_dir():
|
||||
extension = " " if i == len(entries) - 1 else "│ "
|
||||
walk(entry, prefix + extension, level + 1)
|
||||
|
||||
tree_output.append(f"{base.name}/")
|
||||
walk(base)
|
||||
return "\n".join(tree_output)
|
||||
|
||||
@mcp.tool(description="Devuelve información detallada sobre un archivo: tamaño en bytes, fecha de modificación y tipo.")
|
||||
def file_info(path: str) -> dict:
|
||||
fpath = safe_path(path)
|
||||
if not fpath.exists():
|
||||
raise FileNotFoundError("Archivo no encontrado.")
|
||||
return {
|
||||
"nombre": fpath.name,
|
||||
"tipo": "carpeta" if fpath.is_dir() else "archivo",
|
||||
"tamaño_bytes": fpath.stat().st_size,
|
||||
"última_modificación": datetime.fromtimestamp(fpath.stat().st_mtime).isoformat(),
|
||||
"relativo": str(fpath.relative_to(SANDBOX_DIR))
|
||||
}
|
||||
|
||||
@mcp.tool(description="Copia un archivo o carpeta dentro del sandbox a otra ruta.")
|
||||
def copy_file(src: str, dest: str) -> str:
|
||||
src_path = safe_path(src)
|
||||
dest_path = safe_path(dest)
|
||||
if src_path.is_dir():
|
||||
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
|
||||
else:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_path, dest_path)
|
||||
return "Copia completada."
|
||||
|
||||
@mcp.tool(description="Mueve o renombra un archivo o carpeta dentro del sandbox.")
|
||||
def move_file(src: str, dest: str) -> str:
|
||||
src_path = safe_path(src)
|
||||
dest_path = safe_path(dest)
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
src_path.rename(dest_path)
|
||||
return "Movimiento completado."
|
||||
|
||||
@mcp.tool(description="Elimina todos los archivos y subcarpetas dentro de una carpeta del sandbox.")
|
||||
def clear_folder(path: str) -> str:
|
||||
folder_path = safe_path(path)
|
||||
if not folder_path.is_dir():
|
||||
raise NotADirectoryError("La ruta no es una carpeta.")
|
||||
for item in folder_path.iterdir():
|
||||
if item.is_file() or item.is_symlink():
|
||||
item.unlink()
|
||||
elif item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
return "Carpeta vaciada."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http", host="127.0.0.1", port=4201, path="/fs")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(description="Suma dos números enteros.")
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
@mcp.tool(description="Resta dos números enteros.")
|
||||
def subtract(a: int, b: int) -> int:
|
||||
return a - b
|
||||
|
||||
@mcp.tool(description="Multiplica dos números enteros.")
|
||||
def multiply(a: int, b: int) -> int:
|
||||
return a * b
|
||||
|
||||
@mcp.tool(description="Divide dos números y devuelve el resultado flotante.")
|
||||
def divide(a: float, b: float) -> float:
|
||||
if b == 0:
|
||||
raise ValueError("No se puede dividir entre cero.")
|
||||
return a / b
|
||||
|
||||
@mcp.tool(description="Calcula el módulo de dos números enteros.")
|
||||
def modulo(a: int, b: int) -> int:
|
||||
return a % b
|
||||
|
||||
@mcp.tool(description="Concatena dos cadenas de texto.")
|
||||
def concat(a: str, b: str) -> str:
|
||||
return a + b
|
||||
|
||||
@mcp.tool(description="Devuelve la longitud de una cadena.")
|
||||
def string_length(s: str) -> int:
|
||||
return len(s)
|
||||
|
||||
@mcp.tool(description="Convierte una cadena a mayúsculas.")
|
||||
def to_upper(s: str) -> str:
|
||||
return s.upper()
|
||||
|
||||
@mcp.tool(description="Convierte una cadena a minúsculas.")
|
||||
def to_lower(s: str) -> str:
|
||||
return s.lower()
|
||||
|
||||
@mcp.tool(description="Devuelve la suma de todos los elementos en una lista de enteros.")
|
||||
def sum_list(numbers: list[int]) -> int:
|
||||
return sum(numbers)
|
||||
|
||||
@mcp.tool(description="Devuelve el valor máximo en una lista de enteros.")
|
||||
def max_in_list(numbers: list[int]) -> int:
|
||||
return max(numbers)
|
||||
|
||||
@mcp.tool(description="Verifica si un número es par.")
|
||||
def is_even(n: int) -> bool:
|
||||
return n % 2 == 0
|
||||
|
||||
@mcp.tool(description="Verifica si una cadena es un palíndromo.")
|
||||
def is_palindrome(s: str) -> bool:
|
||||
return s == s[::-1]
|
||||
|
||||
@mcp.tool(description="Calcula el factorial de un número entero positivo.")
|
||||
def factorial(n: int) -> int:
|
||||
if n < 0:
|
||||
raise ValueError("El factorial no está definido para negativos.")
|
||||
if n == 0:
|
||||
return 1
|
||||
result = 1
|
||||
for i in range(1, n + 1):
|
||||
result *= i
|
||||
return result
|
||||
|
||||
@mcp.tool(description="Devuelve los primeros n números de Fibonacci.")
|
||||
def fibonacci(n: int) -> list[int]:
|
||||
if n <= 0:
|
||||
return []
|
||||
seq = [0, 1]
|
||||
while len(seq) < n:
|
||||
seq.append(seq[-1] + seq[-2])
|
||||
return seq[:n]
|
||||
|
||||
@mcp.tool(description="Devuelve si un número es primo.")
|
||||
def is_prime(n: int) -> bool:
|
||||
if n <= 1:
|
||||
return False
|
||||
for i in range(2, int(n**0.5) + 1):
|
||||
if n % i == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http", host="127.0.0.1", port=4200, path="/math")
|
||||
|
||||
# mcp.run(transport="stdio")
|
||||
@@ -0,0 +1,69 @@
|
||||
from fastmcp import FastMCP
|
||||
import uuid
|
||||
import datetime
|
||||
import socket
|
||||
import platform
|
||||
import os
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(description="Genera un UUID versión 4.")
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@mcp.tool(description="Devuelve la fecha y hora actuales en formato ISO 8601.")
|
||||
def current_datetime() -> str:
|
||||
return datetime.datetime.now().isoformat()
|
||||
|
||||
@mcp.tool(description="Devuelve solo la fecha actual.")
|
||||
def current_date() -> str:
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
@mcp.tool(description="Devuelve el nombre del host actual.")
|
||||
def get_hostname() -> str:
|
||||
return socket.gethostname()
|
||||
|
||||
@mcp.tool(description="Devuelve el sistema operativo actual.")
|
||||
def get_os() -> str:
|
||||
return platform.system()
|
||||
|
||||
@mcp.tool(description="Devuelve el nombre del usuario actual del sistema.")
|
||||
def get_current_user() -> str:
|
||||
return os.getlogin()
|
||||
|
||||
@mcp.tool(description="Invierte un valor booleano.")
|
||||
def invert_boolean(flag: bool) -> bool:
|
||||
return not flag
|
||||
|
||||
# @mcp.tool(description="Devuelve los archivos y carpetas del directorio actual.")
|
||||
# def list_current_directory() -> list[str]:
|
||||
# return os.listdir()
|
||||
|
||||
# @mcp.tool(description="Crea un archivo con un nombre dado.")
|
||||
# def create_file(filename: str) -> str:
|
||||
# with open(filename, "w") as f:
|
||||
# f.write("")
|
||||
# return f"Archivo '{filename}' creado."
|
||||
|
||||
# @mcp.tool(description="Lee el contenido de un archivo de texto dado.")
|
||||
# def read_file(filename: str) -> str:
|
||||
# with open(filename, "r") as f:
|
||||
# return f.read()
|
||||
|
||||
# @mcp.tool(description="Escribe contenido a un archivo, sobrescribiéndolo.")
|
||||
# def write_file(filename: str, content: str) -> str:
|
||||
# with open(filename, "w") as f:
|
||||
# f.write(content)
|
||||
# return f"Contenido escrito en '{filename}'."
|
||||
|
||||
@mcp.tool(description="Devuelve el número de CPUs disponibles en el sistema.")
|
||||
def get_cpu_count() -> int:
|
||||
return os.cpu_count()
|
||||
|
||||
@mcp.tool(description="Devuelve el timestamp actual (UNIX).")
|
||||
def current_timestamp() -> float:
|
||||
return datetime.datetime.now().timestamp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http", host="127.0.0.1", port=4300, path="/tools")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Literal
|
||||
|
||||
class MemoryConvABC(ABC):
|
||||
"""
|
||||
Interfaz abstracta para memorias de conversación multi-turno.
|
||||
"""
|
||||
|
||||
def __init__(self, k: int = 10):
|
||||
"""
|
||||
:param k: Número máximo de turnos (pares user/assistant) a retornar.
|
||||
"""
|
||||
self.k = k
|
||||
|
||||
@abstractmethod
|
||||
def guardar_turno(self, rol: Literal["user", "assistant"], contenido: str) -> None:
|
||||
"""
|
||||
Guarda un mensaje de un turno de conversación.
|
||||
Se guarda secuencialmente y se asume que el orden es respetado.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cargar_historial_chat(self) -> list[dict]:
|
||||
"""
|
||||
Devuelve los últimos `k*2` mensajes como lista de dicts tipo chat:
|
||||
[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def limpiar(self) -> None:
|
||||
"""
|
||||
Limpia completamente la memoria.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,52 @@
|
||||
from sqlalchemy import Table, Column, Integer, String, MetaData, insert, select, delete
|
||||
from typing import Literal
|
||||
|
||||
from domains.Credenciales.postgres_credencial import PostgresCredencial
|
||||
from domains.ConexionSql.Postgres_conexion import PostgresConexion # Usamos la clase específica
|
||||
from domains.Llms.Memory.Base_MemoryConv import MemoryConvABC
|
||||
|
||||
|
||||
class MemoryConvPostgres(MemoryConvABC):
|
||||
def __init__(self, nombre_tabla: str, k: int, credencial: PostgresCredencial):
|
||||
"""
|
||||
:param nombre_tabla: Nombre de la tabla en PostgreSQL.
|
||||
:param k: Número de pares user/assistant a recuperar.
|
||||
:param credencial: Credencial para conectar a PostgreSQL.
|
||||
"""
|
||||
super().__init__(k=k)
|
||||
self.conexion = PostgresConexion(credencial) # Se instancia directamente con la credencial
|
||||
self.nombre_tabla = nombre_tabla
|
||||
|
||||
self.metadata = MetaData()
|
||||
self.tabla = Table(
|
||||
self.nombre_tabla,
|
||||
self.metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("rol", String, nullable=False),
|
||||
Column("contenido", String, nullable=False),
|
||||
)
|
||||
|
||||
# Crea la tabla si no existe
|
||||
self.metadata.create_all(self.conexion._engine)
|
||||
|
||||
def guardar_turno(self, rol: Literal["user", "assistant"], contenido: str) -> None:
|
||||
stmt = insert(self.tabla).values(rol=rol, contenido=contenido)
|
||||
with self.conexion.get_session() as session:
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
def cargar_historial_chat(self) -> list[dict]:
|
||||
stmt = (
|
||||
select(self.tabla.c.rol, self.tabla.c.contenido)
|
||||
.order_by(self.tabla.c.id.desc())
|
||||
.limit(self.k * 2)
|
||||
)
|
||||
with self.conexion.get_session() as session:
|
||||
result = session.execute(stmt).fetchall()
|
||||
return [{"role": r.rol, "content": r.contenido} for r in reversed(result)]
|
||||
|
||||
def limpiar(self) -> None:
|
||||
stmt = delete(self.tabla)
|
||||
with self.conexion.get_session() as session:
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
@@ -0,0 +1,30 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class ModeloABC(ABC):
|
||||
"""
|
||||
Clase base para definir la interfaz de un modelo conversacional.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 1.0,
|
||||
top_k: int = None,
|
||||
frecuencia_penalizacion: float = 0.0,
|
||||
num_tokens_maximos: int = 512
|
||||
):
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
self.top_p = top_p
|
||||
self.top_k = top_k
|
||||
self.frecuencia_penalizacion = frecuencia_penalizacion
|
||||
self.num_tokens_maximos = num_tokens_maximos
|
||||
|
||||
@abstractmethod
|
||||
async def responder(self, prompt: str, system_prompt: str = "", stream: bool = False, **kwargs) -> str:
|
||||
"""
|
||||
Devuelve una respuesta a partir de un prompt y configuración del modelo.
|
||||
Este método debe implementarse de forma asíncrona en las subclases.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,67 @@
|
||||
from domains.Llms.Modelos.Base_model import ModeloABC
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
from typing import AsyncGenerator, Union
|
||||
from domains.ConexionApis.Ollama_cliente import OllamaCliente # Asegúrate de importar correctamente
|
||||
import asyncio
|
||||
|
||||
class ModeloOllama(ModeloABC):
|
||||
def __init__(
|
||||
self,
|
||||
cliente: OllamaCliente,
|
||||
model: str = "llama3",
|
||||
id: str = None,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 1.0,
|
||||
top_k: int = None,
|
||||
frecuencia_penalizacion: float = 0.0,
|
||||
num_tokens_maximos: int = 512
|
||||
):
|
||||
if not isinstance(cliente, OllamaCliente):
|
||||
raise TypeError("El parámetro 'cliente' debe ser una instancia de OllamaCliente")
|
||||
|
||||
|
||||
self.id = id if id else GeneradorIDUnico("MOOL").generar()
|
||||
super().__init__(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
frecuencia_penalizacion=frecuencia_penalizacion,
|
||||
num_tokens_maximos=num_tokens_maximos
|
||||
)
|
||||
self.cliente = cliente
|
||||
|
||||
async def responder(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str = "",
|
||||
stream: bool = False,
|
||||
**kwargs
|
||||
) -> Union[str, AsyncGenerator[str, None]]:
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
def sync_call():
|
||||
return self.cliente.chat_completion(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
max_tokens=self.num_tokens_maximos,
|
||||
frequency_penalty=self.frecuencia_penalizacion,
|
||||
stream=stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
resultado = await loop.run_in_executor(None, sync_call)
|
||||
|
||||
if stream:
|
||||
async def generador():
|
||||
for token in resultado:
|
||||
yield token
|
||||
return generador()
|
||||
else:
|
||||
return resultado.choices[0].message.content
|
||||
@@ -0,0 +1,90 @@
|
||||
from domains.Llms.Modelos.Base_model import ModeloABC
|
||||
from domains.ConexionApis.OpenAi_conexion import OpenAICliente
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Union
|
||||
|
||||
class ModeloOpenAI(ModeloABC):
|
||||
def __init__(
|
||||
self,
|
||||
cliente: OpenAICliente,
|
||||
model: str = "gpt-4o",
|
||||
id: str = None,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 1.0,
|
||||
top_k: int = None,
|
||||
frecuencia_penalizacion: float = 0.0,
|
||||
num_tokens_maximos: int = 512,
|
||||
use_legacy: bool = False
|
||||
):
|
||||
# Generar ID con prefijo MOPA si no fue proporcionado
|
||||
self.id = id if id is not None else GeneradorIDUnico("MOPA").generar()
|
||||
|
||||
# Inicializar resto del modelo base
|
||||
super().__init__(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
frecuencia_penalizacion=frecuencia_penalizacion,
|
||||
num_tokens_maximos=num_tokens_maximos
|
||||
)
|
||||
|
||||
# Asignar cliente e indicadores adicionales
|
||||
self.cliente = cliente
|
||||
self.use_legacy = use_legacy
|
||||
|
||||
async def responder(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: str = "",
|
||||
stream: bool = False,
|
||||
**kwargs
|
||||
) -> Union[str, AsyncGenerator[str, None]]:
|
||||
if self.use_legacy:
|
||||
if stream:
|
||||
raise NotImplementedError("El modo legacy no soporta streaming.")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
respuesta = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.cliente.completion(
|
||||
model=self.model,
|
||||
prompt=prompt,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
max_tokens=self.num_tokens_maximos,
|
||||
frequency_penalty=self.frecuencia_penalizacion,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
return respuesta.choices[0].text.strip()
|
||||
|
||||
# Construcción de mensajes estilo Chat
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
def sync_call():
|
||||
return self.cliente.chat_completion(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
max_tokens=self.num_tokens_maximos,
|
||||
frequency_penalty=self.frecuencia_penalizacion,
|
||||
stream=stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
resultado = await loop.run_in_executor(None, sync_call)
|
||||
|
||||
if stream:
|
||||
async def generador():
|
||||
for token in resultado:
|
||||
yield token
|
||||
return generador()
|
||||
else:
|
||||
return resultado.choices[0].message.content
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.base import Base
|
||||
from domains.Llms.Modelos.Openai_model import ModeloOpenAI # Clase real de lógica
|
||||
|
||||
# ----------------------
|
||||
# Cargar clave maestra
|
||||
# ----------------------
|
||||
from entrypoint import ENV_PATH
|
||||
load_dotenv(ENV_PATH)
|
||||
pssword = os.getenv('MASTER_PASSWORD')
|
||||
if pssword is None:
|
||||
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class ModeloOpenAIConfigModel(Base, Model_base):
|
||||
__tablename__ = 'modelo_openai_configs'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
model = Column(String, nullable=False)
|
||||
temperature = Column(Float, default=0.7, nullable=False)
|
||||
top_p = Column(Float, default=1.0, nullable=False)
|
||||
top_k = Column(Integer, nullable=True)
|
||||
|
||||
frecuencia_penalizacion = Column(Float, default=0.0, nullable=False)
|
||||
num_tokens_maximos = Column(Integer, default=512, nullable=False)
|
||||
|
||||
use_legacy = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class ModeloOpenAIConfigMapper(Mapper_base[ModeloOpenAI, ModeloOpenAIConfigModel]):
|
||||
|
||||
@staticmethod
|
||||
def to_model(obj: ModeloOpenAI) -> ModeloOpenAIConfigModel:
|
||||
return ModeloOpenAIConfigModel(
|
||||
id=obj.id,
|
||||
model=obj.model,
|
||||
temperature=obj.temperature,
|
||||
top_p=obj.top_p,
|
||||
top_k=obj.top_k,
|
||||
frecuencia_penalizacion=obj.frecuencia_penalizacion,
|
||||
num_tokens_maximos=obj.num_tokens_maximos,
|
||||
use_legacy=obj.use_legacy
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: ModeloOpenAIConfigModel, cliente: Optional[object] = None) -> ModeloOpenAI:
|
||||
return ModeloOpenAI(
|
||||
id=model.id,
|
||||
cliente=cliente,
|
||||
model=model.model,
|
||||
temperature=model.temperature,
|
||||
top_p=model.top_p,
|
||||
top_k=model.top_k,
|
||||
frecuencia_penalizacion=model.frecuencia_penalizacion,
|
||||
num_tokens_maximos=model.num_tokens_maximos,
|
||||
use_legacy=model.use_legacy
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: ModeloOpenAI) -> dict:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"model": obj.model,
|
||||
"temperature": obj.temperature,
|
||||
"top_p": obj.top_p,
|
||||
"top_k": obj.top_k,
|
||||
"frecuencia_penalizacion": obj.frecuencia_penalizacion,
|
||||
"num_tokens_maximos": obj.num_tokens_maximos,
|
||||
"use_legacy": obj.use_legacy
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict, cliente: Optional[object] = None) -> ModeloOpenAI:
|
||||
return ModeloOpenAI(
|
||||
id=data["id"],
|
||||
cliente=cliente,
|
||||
model=data["model"],
|
||||
temperature=data["temperature"],
|
||||
top_p=data["top_p"],
|
||||
top_k=data["top_k"],
|
||||
frecuencia_penalizacion=data["frecuencia_penalizacion"],
|
||||
num_tokens_maximos=data["num_tokens_maximos"],
|
||||
use_legacy=data["use_legacy"]
|
||||
)
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class ModeloOpenAIConfigRepo(Repo_base[ModeloOpenAIConfigModel, ModeloOpenAI]):
|
||||
def __init__(self, conexion: ConexionBase, cliente: object):
|
||||
super().__init__(
|
||||
session=conexion.get_session(),
|
||||
modelo=ModeloOpenAIConfigModel,
|
||||
mapper=ModeloOpenAIConfigMapper
|
||||
)
|
||||
self.cliente = cliente # Necesario para construir el dominio con lógica
|
||||
|
||||
def get_by_id(self, id_: str) -> ModeloOpenAI | None:
|
||||
model = self.session.get(self.Modelo, id_)
|
||||
return self.Mapper.from_model(model, self.cliente) if model else None
|
||||
|
||||
def get_all(self) -> list[ModeloOpenAI]:
|
||||
models = self.session.query(self.Modelo).all()
|
||||
return [self.Mapper.from_model(m, self.cliente) for m in models]
|
||||
@@ -0,0 +1,62 @@
|
||||
from loguru import logger
|
||||
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ConexionSql.Postgres_conexion import PostgresConexion
|
||||
from domains.Credenciales.postgres_credencial import PostgresCredencial
|
||||
|
||||
class LoggerDB:
|
||||
_sink_removido = False # ← evita múltiples remove() si se crean varias instancias
|
||||
|
||||
def __init__(self, credencial: PostgresCredencial, nombre_tabla: str, created_by: str = None):
|
||||
|
||||
# 🔥 Elimina todos los sinks activos, incluso los automáticos
|
||||
logger.remove()
|
||||
|
||||
self.conexion = PostgresConexion(credencial)
|
||||
self.engine = self.conexion.get_engine()
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.nombre_tabla = nombre_tabla
|
||||
self.created_by = created_by
|
||||
|
||||
self.modelo_logger = self._generar_modelo_logger()
|
||||
self._crear_tabla_si_no_existe()
|
||||
logger.add(self._sink, level="DEBUG")
|
||||
|
||||
def _generar_modelo_logger(self):
|
||||
class LoggerTable(Model_base):
|
||||
__tablename__ = self.nombre_tabla
|
||||
__table_args__ = {'extend_existing': True} # 👈 Esta línea evita el error
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
nivel = Column(String, nullable=False)
|
||||
mensaje = Column(Text, nullable=False)
|
||||
fecha = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
modulo = Column(String, nullable=True)
|
||||
funcion = Column(String, nullable=True)
|
||||
linea = Column(Integer, nullable=True)
|
||||
return LoggerTable
|
||||
|
||||
def _crear_tabla_si_no_existe(self):
|
||||
self.modelo_logger.__table__.create(self.engine, checkfirst=True)
|
||||
|
||||
def _sink(self, message):
|
||||
record = message.record
|
||||
try:
|
||||
session = self.Session()
|
||||
log_entry = self.modelo_logger(
|
||||
nivel=record["level"].name,
|
||||
mensaje=record["message"],
|
||||
fecha=record["time"],
|
||||
modulo=record["module"],
|
||||
funcion=record["function"],
|
||||
linea=record["line"],
|
||||
sys_created_by=self.created_by
|
||||
)
|
||||
session.add(log_entry)
|
||||
session.commit()
|
||||
session.close()
|
||||
except SQLAlchemyError as e:
|
||||
print(f"[LoggerDB] Error guardando log en BD: {e}")
|
||||
@@ -0,0 +1,190 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
import random
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .Tab import Tab
|
||||
|
||||
class ElementoWeb:
|
||||
def __init__(self, tab: "Tab", object_id: Optional[str]):
|
||||
self.tab = tab
|
||||
self.object_id = object_id
|
||||
self._node_id = None # Lazy resolved
|
||||
|
||||
@classmethod
|
||||
def from_node(cls, tab: "Tab", node_id: int) -> "ElementoWeb":
|
||||
inst = cls(tab, object_id=None)
|
||||
inst._node_id = node_id
|
||||
return inst
|
||||
|
||||
async def _asegurar_object_id(self):
|
||||
if not self.object_id and self._node_id:
|
||||
try:
|
||||
resolved = await self.tab._enviar("DOM.resolveNode", {"nodeId": self._node_id})
|
||||
self.object_id = resolved["object"]["objectId"]
|
||||
except Exception as e:
|
||||
print(f"⚠️ No se pudo resolver objectId desde nodeId: {e}")
|
||||
|
||||
async def scroll_into_view(self):
|
||||
try:
|
||||
await self._asegurar_object_id()
|
||||
await self.tab._enviar("Runtime.callFunctionOn", {
|
||||
"objectId": self.object_id,
|
||||
"functionDeclaration": "function() { this.scrollIntoView({block: 'center'}); }",
|
||||
"awaitPromise": True
|
||||
})
|
||||
if self.tab.verbose:
|
||||
print("📜 Elemento desplazado a la vista.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al hacer scroll hacia el elemento: {e}")
|
||||
|
||||
async def click(self):
|
||||
try:
|
||||
await self.scroll_into_view()
|
||||
await self._asegurar_object_id()
|
||||
if not self.object_id:
|
||||
raise ValueError("No se puede obtener objectId del elemento para hacer click.")
|
||||
|
||||
# Intenta obtener coordenadas del nodo
|
||||
node_result = await self.tab._enviar("DOM.describeNode", {
|
||||
"objectId": self.object_id
|
||||
})
|
||||
node_id = node_result["node"]["nodeId"]
|
||||
|
||||
try:
|
||||
box_model = await self.tab._enviar("DOM.getBoxModel", {"nodeId": node_id})
|
||||
content = box_model["model"]["content"]
|
||||
x = (content[0] + content[2]) / 2
|
||||
y = (content[1] + content[5]) / 2
|
||||
except:
|
||||
quads_result = await self.tab._enviar("DOM.getContentQuads", {"nodeId": node_id})
|
||||
quad = quads_result["quads"][0]
|
||||
x = (quad[0] + quad[4]) / 2
|
||||
y = (quad[1] + quad[5]) / 2
|
||||
|
||||
# 🧠 Enfocar el elemento antes de clickear
|
||||
await self.tab._enviar("DOM.focus", {
|
||||
"objectId": self.object_id
|
||||
})
|
||||
|
||||
# 🎯 Movimiento humanoide opcional
|
||||
start_x, start_y = x + random.uniform(-100, 100), y + random.uniform(-100, 100)
|
||||
steps = random.randint(5, 12)
|
||||
for i in range(1, steps + 1):
|
||||
curr_x = start_x + (x - start_x) * i / steps + random.uniform(-1, 1)
|
||||
curr_y = start_y + (y - start_y) * i / steps + random.uniform(-1, 1)
|
||||
await self.tab._enviar("Input.dispatchMouseEvent", {
|
||||
"type": "mouseMoved",
|
||||
"x": curr_x,
|
||||
"y": curr_y,
|
||||
})
|
||||
await asyncio.sleep(random.uniform(0.01, 0.05))
|
||||
|
||||
# 👆 Mouse Down
|
||||
await self.tab._enviar("Input.dispatchMouseEvent", {
|
||||
"type": "mousePressed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1
|
||||
})
|
||||
|
||||
await asyncio.sleep(random.uniform(0.05, 0.15))
|
||||
|
||||
# 👇 Mouse Up
|
||||
await self.tab._enviar("Input.dispatchMouseEvent", {
|
||||
"type": "mouseReleased",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1
|
||||
})
|
||||
|
||||
await asyncio.sleep(random.uniform(0.01, 0.05))
|
||||
|
||||
# 🖱️ Click manual adicional
|
||||
await self.tab._enviar("Input.dispatchMouseEvent", {
|
||||
"type": "mouseClicked",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1
|
||||
})
|
||||
|
||||
if self.tab.verbose:
|
||||
print(f"🖱️ Click humano simulado en ({x:.1f}, {y:.1f})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al hacer click físico: {e}")
|
||||
print("🧪 Intentando fallback con JavaScript click()...")
|
||||
await self.click_js()
|
||||
|
||||
async def click_js(self):
|
||||
try:
|
||||
await self._asegurar_object_id()
|
||||
if not self.object_id:
|
||||
print("⚠️ No se puede hacer click JS: objectId no disponible.")
|
||||
return
|
||||
await self.tab._enviar("Runtime.callFunctionOn", {
|
||||
"objectId": self.object_id,
|
||||
"functionDeclaration": "function() { this.click(); }",
|
||||
"awaitPromise": True
|
||||
})
|
||||
if self.tab.verbose:
|
||||
print("🖱️ Click simulado por JavaScript (element.click())")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al ejecutar click en JS: {e}")
|
||||
|
||||
async def obtener_texto(self) -> Optional[str]:
|
||||
try:
|
||||
await self._asegurar_object_id()
|
||||
result = await self.tab._enviar("Runtime.callFunctionOn", {
|
||||
"objectId": self.object_id,
|
||||
"functionDeclaration": "function() { return this.textContent; }",
|
||||
"returnByValue": True
|
||||
})
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al obtener texto del elemento: {e}")
|
||||
return None
|
||||
|
||||
async def escribir_texto(self, texto: str):
|
||||
try:
|
||||
await self._asegurar_object_id()
|
||||
await self.tab._enviar("Runtime.callFunctionOn", {
|
||||
"objectId": self.object_id,
|
||||
"functionDeclaration": f"function() {{ this.value = {json.dumps(texto)}; this.dispatchEvent(new Event('input')); }}",
|
||||
"awaitPromise": True
|
||||
})
|
||||
if self.tab.verbose:
|
||||
print(f"⌨️ Texto escrito en elemento: '{texto}'")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al escribir texto: {e}")
|
||||
|
||||
|
||||
async def encontrar_hijo_clickeable(self) -> Optional["ElementoWeb"]:
|
||||
try:
|
||||
await self._asegurar_object_id()
|
||||
resultado = await self.tab._enviar("Runtime.callFunctionOn", {
|
||||
"objectId": self.object_id,
|
||||
"functionDeclaration": """
|
||||
function() {
|
||||
const candidatos = this.querySelectorAll("span, div, a, button");
|
||||
for (const el of candidatos) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const visible = style.display !== "none" && style.visibility !== "hidden";
|
||||
const interactivo = style.pointerEvents !== "none";
|
||||
if (visible && interactivo) return el;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
""",
|
||||
"returnByValue": False
|
||||
})
|
||||
if "result" in resultado and "objectId" in resultado["result"]:
|
||||
return ElementoWeb(self.tab, resultado["result"]["objectId"])
|
||||
except Exception as e:
|
||||
print(f"⚠️ No se pudo encontrar hijo clickeable: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,193 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Optional
|
||||
import aiohttp
|
||||
|
||||
|
||||
class Navegador:
|
||||
def __init__(self,
|
||||
chrome_path: str,
|
||||
user_data_dir: str,
|
||||
id: Optional[int] = None,
|
||||
download_dir: Optional[str] = None,
|
||||
debugging_port: int = 9222,
|
||||
headless: bool = False,
|
||||
user_agent: Optional[str] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"):
|
||||
self.chrome_path = chrome_path
|
||||
self.user_data_dir = user_data_dir
|
||||
self.id = id
|
||||
self.download_dir = download_dir or os.path.join(self.user_data_dir, "downloads")
|
||||
self.debugging_port = debugging_port
|
||||
self.headless = headless
|
||||
self.user_agent = user_agent
|
||||
self.chrome_process: Optional[subprocess.Popen] = None
|
||||
|
||||
async def _esperar_debugger(self, timeout=10):
|
||||
url = f"http://127.0.0.1:{self.debugging_port}/json"
|
||||
for _ in range(timeout * 10): # 10 intentos por segundo
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
print("✅ Chrome listo para debugging.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.1)
|
||||
raise RuntimeError("❌ Chrome no respondió en el puerto de debugging.")
|
||||
|
||||
def _preconfigurar_preferencias(self):
|
||||
prefs_path = os.path.join(self.user_data_dir, "Default", "Preferences")
|
||||
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
prefs = {
|
||||
"profile": {
|
||||
"exit_type": "Normal",
|
||||
"exited_cleanly": True
|
||||
},
|
||||
"browser": {
|
||||
"has_seen_welcome_page": True
|
||||
},
|
||||
"distribution": {
|
||||
"skip_first_run_ui": True
|
||||
},
|
||||
"download": {
|
||||
"default_directory": self.download_dir,
|
||||
"prompt_for_download": False,
|
||||
"directory_upgrade": True,
|
||||
"extensions_to_open": ""
|
||||
},
|
||||
"savefile": {
|
||||
"default_directory": self.download_dir
|
||||
}
|
||||
}
|
||||
|
||||
if os.path.exists(prefs_path):
|
||||
try:
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
existing.update(prefs)
|
||||
prefs = existing
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(prefs, f, indent=2)
|
||||
|
||||
def _build_args(self):
|
||||
os.makedirs(self.user_data_dir, exist_ok=True)
|
||||
self._preconfigurar_preferencias()
|
||||
|
||||
args = [
|
||||
f"--remote-debugging-port={self.debugging_port}",
|
||||
f"--user-data-dir={self.user_data_dir}",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
# "--disable-web-security",
|
||||
# "--disable-extensions",
|
||||
# "--disable-dev-shm-usage",
|
||||
"--disable-infobars",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-default-apps",
|
||||
"--mute-audio",
|
||||
"--window-size=1024,1024",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-features=DefaultBrowserPrompt",
|
||||
"--disable-component-update",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-client-side-phishing-detection",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--metrics-recording-only",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
|
||||
|
||||
]
|
||||
|
||||
if self.headless:
|
||||
args.append("--headless=new")
|
||||
|
||||
if self.user_agent:
|
||||
args.append(f"--user-agent={self.user_agent}")
|
||||
|
||||
return args
|
||||
|
||||
|
||||
|
||||
async def inyectar_spoof_chrome(self):
|
||||
script = """
|
||||
window.chrome = {
|
||||
app: {
|
||||
isInstalled: false,
|
||||
InstallState: {
|
||||
DISABLED: 'disabled',
|
||||
INSTALLED: 'installed',
|
||||
NOT_INSTALLED: 'not_installed'
|
||||
},
|
||||
RunningState: {
|
||||
CANNOT_RUN: 'cannot_run',
|
||||
READY_TO_RUN: 'ready_to_run',
|
||||
RUNNING: 'running'
|
||||
}
|
||||
},
|
||||
runtime: {
|
||||
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
||||
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
||||
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
||||
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }
|
||||
}
|
||||
};
|
||||
"""
|
||||
|
||||
url = f"http://127.0.0.1:{self.debugging_port}/json"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as resp:
|
||||
targets = await resp.json()
|
||||
|
||||
for target in targets:
|
||||
if "webSocketDebuggerUrl" not in target:
|
||||
continue
|
||||
|
||||
target_id = target["id"]
|
||||
async with session.post(
|
||||
f"http://127.0.0.1:{self.debugging_port}/json/protocol",
|
||||
json={"targetId": target_id}
|
||||
):
|
||||
pass # CDP protocol fetch optional
|
||||
|
||||
async with session.post(
|
||||
f"http://127.0.0.1:{self.debugging_port}/json/send",
|
||||
json={
|
||||
"id": 1,
|
||||
"method": "Page.addScriptToEvaluateOnNewDocument",
|
||||
"params": {"source": script}
|
||||
}
|
||||
) as inject_resp:
|
||||
if inject_resp.status == 200:
|
||||
print("✅ chrome.* spoof inyectado.")
|
||||
|
||||
|
||||
async def iniciar(self):
|
||||
args = self._build_args()
|
||||
self.chrome_process = subprocess.Popen([self.chrome_path] + args)
|
||||
print(f"Chrome iniciado (headless={self.headless}). Esperando disponibilidad del debugger...")
|
||||
await self._esperar_debugger()
|
||||
await self.inyectar_spoof_chrome()
|
||||
|
||||
async def cerrar(self):
|
||||
if self.chrome_process and self.chrome_process.poll() is None:
|
||||
self.chrome_process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.to_thread(self.chrome_process.wait), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
self.chrome_process.kill()
|
||||
print("🛑 Chrome cerrado correctamente.")
|
||||
@@ -0,0 +1,138 @@
|
||||
import aiohttp
|
||||
import websockets
|
||||
import json
|
||||
import asyncio
|
||||
from .Tab import Tab
|
||||
from typing import Optional
|
||||
|
||||
|
||||
|
||||
class Scrapper:
|
||||
def __init__(self, debugging_url: str = "http://127.0.0.1:9222"):
|
||||
self.debugging_url = debugging_url
|
||||
self.tabs: list[Tab] = []
|
||||
|
||||
async def _crear_tab_websocket_url(self, target_url: str = "about:blank") -> str:
|
||||
"""
|
||||
Crea una nueva pestaña usando el método oficial Target.createTarget
|
||||
y devuelve su WebSocketDebuggerUrl.
|
||||
"""
|
||||
# 1. Obtener el WebSocket general del browser (root)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{self.debugging_url}/json/version") as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError("No se pudo obtener información del navegador")
|
||||
data = await resp.json()
|
||||
browser_ws_url = data["webSocketDebuggerUrl"]
|
||||
|
||||
# 2. Conectarse al WebSocket del browser
|
||||
async with websockets.connect(browser_ws_url) as websocket:
|
||||
# 3. Enviar comando para crear target
|
||||
msg_id = 1
|
||||
await websocket.send(json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "Target.createTarget",
|
||||
"params": {
|
||||
"url": target_url,
|
||||
"newWindow": False
|
||||
}
|
||||
}))
|
||||
|
||||
# 4. Esperar respuesta con el targetId
|
||||
while True:
|
||||
respuesta = await websocket.recv()
|
||||
data = json.loads(respuesta)
|
||||
if data.get("id") == msg_id:
|
||||
target_id = data["result"]["targetId"]
|
||||
break
|
||||
|
||||
# 5. Esperar a que el target aparezca en /json
|
||||
for _ in range(30): # máximo ~3 segundos
|
||||
await asyncio.sleep(0.1)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{self.debugging_url}/json") as resp:
|
||||
if resp.status == 200:
|
||||
tabs = await resp.json()
|
||||
for tab in tabs:
|
||||
if tab.get("id") == target_id:
|
||||
return tab["webSocketDebuggerUrl"]
|
||||
|
||||
raise RuntimeError("No se pudo obtener el WebSocket de la nueva pestaña")
|
||||
|
||||
async def nueva_tab(self, url: str = "", wait_time: float = 5.0) -> Tab:
|
||||
websocket_url = await self._crear_tab_websocket_url()
|
||||
tab = await Tab.crear_desde_websocket(websocket_url)
|
||||
self.tabs.append(tab)
|
||||
|
||||
if url:
|
||||
print(f"🌍 Navegando a: {url}")
|
||||
await tab.navegar(url, wait_time)
|
||||
else:
|
||||
print("⚠️ No se especificó URL. La pestaña se creó pero no se navegó a ninguna página.")
|
||||
|
||||
return tab
|
||||
|
||||
async def cerrar_todos(self):
|
||||
for tab in list(self.tabs):
|
||||
await tab.cerrar()
|
||||
self.tabs.clear()
|
||||
|
||||
def get_tab(self, identifier: str) -> Optional[Tab]:
|
||||
"""
|
||||
Devuelve una instancia de Tab según su WebSocket URL o su ID final (extraído del WebSocket URL).
|
||||
Acepta:
|
||||
- ws_url completo: ws://127.0.0.1:9222/devtools/page/XYZ
|
||||
- id directo: XYZ
|
||||
"""
|
||||
for tab in self.tabs:
|
||||
# Comparar directamente contra ws_url
|
||||
if tab.ws_url == identifier:
|
||||
return tab
|
||||
|
||||
# Comparar contra el ID extraído
|
||||
ws_id = tab.ws_url.rsplit("/", 1)[-1]
|
||||
if ws_id == identifier:
|
||||
return tab
|
||||
|
||||
return None
|
||||
|
||||
async def obtener_tabs_existentes(self) -> list[Tab]:
|
||||
"""
|
||||
Recupera todas las pestañas de tipo 'page' que no están ya en self.tabs,
|
||||
las conecta y devuelve como lista. Muestra resumen limpio por consola.
|
||||
"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{self.debugging_url}/json") as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError("No se pudo obtener la lista de pestañas")
|
||||
|
||||
tabs_info = await resp.json()
|
||||
|
||||
print("\n🧾 Pestañas activas (filtradas: solo 'type': 'page'):\n")
|
||||
nuevas_tabs = []
|
||||
for idx, tab_info in enumerate(tabs_info, start=1):
|
||||
tipo = tab_info.get("type")
|
||||
if tipo != "page":
|
||||
continue # Filtrar todo lo que no sea página visible
|
||||
|
||||
ws_url = tab_info.get("webSocketDebuggerUrl")
|
||||
tab_id = tab_info.get("id")
|
||||
title = tab_info.get("title", "<Sin título>")
|
||||
url = tab_info.get("url", "<Sin URL>")
|
||||
|
||||
# Verifica si ya la tienes cargada
|
||||
if any(t.ws_url == ws_url for t in self.tabs):
|
||||
continue
|
||||
|
||||
# Conectar
|
||||
try:
|
||||
tab = await Tab.crear_desde_websocket(ws_url)
|
||||
self.tabs.append(tab)
|
||||
nuevas_tabs.append(tab)
|
||||
except Exception as e:
|
||||
print(f"⚠️ No se pudo conectar a pestaña {tab_id}: {e}")
|
||||
|
||||
if not nuevas_tabs:
|
||||
print("⚠️ No se encontraron nuevas pestañas para agregar.\n")
|
||||
|
||||
return nuevas_tabs
|
||||
@@ -0,0 +1,206 @@
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
import websockets
|
||||
from typing import Optional, List
|
||||
from .ElementoWeb import ElementoWeb
|
||||
import os
|
||||
|
||||
|
||||
class Tab:
|
||||
def __init__(self, websocket: websockets.WebSocketClientProtocol, ws_url: str, verbose: bool = True):
|
||||
self.websocket = websocket
|
||||
self.ws_url = ws_url
|
||||
self._message_id = 0
|
||||
self._pending = {}
|
||||
self._load_event = asyncio.Event()
|
||||
self.verbose = verbose
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
await self.cerrar()
|
||||
|
||||
@classmethod
|
||||
async def crear_desde_websocket(cls, ws_url: str) -> "Tab":
|
||||
websocket = await websockets.connect(ws_url, max_size=10 * 1024 * 1024)
|
||||
tab = cls(websocket, ws_url)
|
||||
asyncio.create_task(tab._recibir_eventos())
|
||||
await tab._enviar("Page.enable")
|
||||
await tab._enviar("Network.enable")
|
||||
return tab
|
||||
|
||||
async def _recibir_eventos(self):
|
||||
async for mensaje in self.websocket:
|
||||
data = json.loads(mensaje)
|
||||
if "id" in data and data["id"] in self._pending:
|
||||
future = self._pending.pop(data["id"])
|
||||
if "result" in data:
|
||||
future.set_result(data["result"])
|
||||
elif "error" in data:
|
||||
future.set_exception(Exception(data["error"]))
|
||||
elif data.get("method") == "Page.loadEventFired":
|
||||
self._load_event.set()
|
||||
|
||||
async def _enviar(self, metodo: str, parametros: Optional[dict] = None, timeout: float = 10.0) -> dict:
|
||||
self._message_id += 1
|
||||
msg_id = self._message_id
|
||||
mensaje = {
|
||||
"id": msg_id,
|
||||
"method": metodo,
|
||||
"params": parametros or {}
|
||||
}
|
||||
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self._pending[msg_id] = future
|
||||
await self.websocket.send(json.dumps(mensaje))
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
|
||||
async def navegar(self, url: str, wait_time: float = 5.0):
|
||||
self._load_event.clear()
|
||||
if self.verbose:
|
||||
print(f"🌍 Navegando a: {url}")
|
||||
await self._enviar("Page.navigate", {"url": url})
|
||||
try:
|
||||
await asyncio.wait_for(self._load_event.wait(), timeout=wait_time)
|
||||
if self.verbose:
|
||||
print("✅ Página cargada correctamente.")
|
||||
except asyncio.TimeoutError:
|
||||
print(f"⚠️ Tiempo de espera agotado ({wait_time}s) al cargar la página.")
|
||||
|
||||
async def evaluar_js(self, js_code: str) -> Optional[str]:
|
||||
try:
|
||||
result = await self._enviar("Runtime.evaluate", {
|
||||
"expression": js_code,
|
||||
"returnByValue": True
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
raise Exception(result["exceptionDetails"])
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al ejecutar JS: {e}")
|
||||
return None
|
||||
|
||||
async def inyectar_archivo_js(self, ruta_archivo: str, reemplazos: dict = None) -> Optional[str]:
|
||||
if not os.path.exists(ruta_archivo):
|
||||
print(f"❌ Archivo JS no encontrado: {ruta_archivo}")
|
||||
return None
|
||||
|
||||
with open(ruta_archivo, "r", encoding="utf-8") as f:
|
||||
js_code = f.read()
|
||||
|
||||
if reemplazos:
|
||||
for key, value in reemplazos.items():
|
||||
js_code = js_code.replace(f"{{{{{key}}}}}", str(value))
|
||||
|
||||
# 🔧 Eliminamos el `return` externo
|
||||
js_code_final = f"(async () => {{\n{js_code}\n}})();"
|
||||
|
||||
try:
|
||||
result = await self._enviar("Runtime.evaluate", {
|
||||
"expression": js_code_final,
|
||||
"returnByValue": True
|
||||
})
|
||||
if "exceptionDetails" in result:
|
||||
raise Exception(result["exceptionDetails"])
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al inyectar JS desde {ruta_archivo}: {e}")
|
||||
return None
|
||||
|
||||
async def obtener_user_agent(self) -> Optional[str]:
|
||||
return await self.evaluar_js("navigator.userAgent")
|
||||
|
||||
async def capturar_screenshot(self, output_path: str = "screenshot.png"):
|
||||
try:
|
||||
result = await self._enviar("Page.captureScreenshot")
|
||||
data = result["data"]
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(base64.b64decode(data))
|
||||
if self.verbose:
|
||||
print(f"📸 Screenshot guardado como {output_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al capturar screenshot: {e}")
|
||||
|
||||
async def cerrar(self):
|
||||
try:
|
||||
if not self.websocket.closed:
|
||||
await self.websocket.close()
|
||||
if self.verbose:
|
||||
print("🛑 WebSocket cerrado.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al cerrar pestaña: {e}")
|
||||
|
||||
async def obtener_html_completo(self) -> Optional[str]:
|
||||
try:
|
||||
result = await self._enviar("Runtime.evaluate", {
|
||||
"expression": "document.documentElement.outerHTML",
|
||||
"returnByValue": True
|
||||
})
|
||||
return result.get("result", {}).get("value")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al obtener HTML: {e}")
|
||||
return None
|
||||
|
||||
async def obtener_dominio(self) -> Optional[str]:
|
||||
try:
|
||||
dominio = await self.evaluar_js("window.location.hostname")
|
||||
if self.verbose and dominio:
|
||||
print(f"🌐 Dominio actual: {dominio}")
|
||||
return dominio
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al obtener dominio: {e}")
|
||||
return None
|
||||
|
||||
async def get_element_by_selector_node(self, selector: str) -> Optional["ElementoWeb"]:
|
||||
try:
|
||||
doc = await self._enviar("DOM.getDocument")
|
||||
root_node_id = doc["root"]["nodeId"]
|
||||
|
||||
result = await self._enviar("DOM.querySelector", {
|
||||
"nodeId": root_node_id,
|
||||
"selector": selector
|
||||
})
|
||||
node_id = result.get("nodeId")
|
||||
|
||||
if not node_id:
|
||||
print(f"⚠️ Nodo no encontrado con selector: {selector}")
|
||||
return None
|
||||
|
||||
return ElementoWeb.from_node(self, node_id=node_id)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al buscar nodo desde DOM.querySelector: {e}")
|
||||
return None
|
||||
|
||||
async def get_elements_by_css_selector(self, selector: str) -> List["ElementoWeb"]:
|
||||
try:
|
||||
result = await self._enviar("Runtime.evaluate", {
|
||||
"expression": f'Array.from(document.querySelectorAll("{selector}"))',
|
||||
"objectGroup": "grupo_elementos_css",
|
||||
"includeCommandLineAPI": True,
|
||||
"returnByValue": False
|
||||
})
|
||||
array_id = result["result"]["objectId"]
|
||||
props = await self._enviar("Runtime.getProperties", {
|
||||
"objectId": array_id,
|
||||
"ownProperties": True
|
||||
})
|
||||
elementos = []
|
||||
for prop in props["result"]:
|
||||
if "value" in prop and "objectId" in prop["value"]:
|
||||
elementos.append(ElementoWeb(self, prop["value"]["objectId"]))
|
||||
if self.verbose:
|
||||
print(f"🔍 Se encontraron {len(elementos)} elementos con el selector CSS '{selector}'.")
|
||||
return elementos
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al buscar elementos por selector CSS '{selector}': {e}")
|
||||
return []
|
||||
|
||||
async def enfocar(self):
|
||||
try:
|
||||
await self._enviar("Page.bringToFront")
|
||||
if self.verbose:
|
||||
print("🪟 Pestaña enfocada (bringToFront).")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error al enfocar pestaña: {e}")
|
||||
@@ -0,0 +1,44 @@
|
||||
import base64
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
class Encriptar_fernet:
|
||||
@staticmethod
|
||||
def _derivar_clave(password: str, salt: bytes) -> bytes:
|
||||
"""
|
||||
Deriva una clave segura a partir de la contraseña y el salt usando PBKDF2HMAC.
|
||||
"""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100_000,
|
||||
backend=default_backend()
|
||||
)
|
||||
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
|
||||
@classmethod
|
||||
def encriptar(cls, texto: str, password: str) -> bytes:
|
||||
"""
|
||||
Encripta un texto con una clave derivada de la contraseña + salt aleatorio.
|
||||
El salt es embebido al inicio del token cifrado.
|
||||
"""
|
||||
salt = os.urandom(16) # 128 bits de salt aleatorio
|
||||
key = cls._derivar_clave(password, salt)
|
||||
fernet = Fernet(key)
|
||||
token = fernet.encrypt(texto.encode('utf-8'))
|
||||
return salt + token # Embebemos el salt al principio
|
||||
|
||||
@classmethod
|
||||
def desencriptar(cls, token_con_salt: bytes, password: str) -> str:
|
||||
"""
|
||||
Extrae el salt del token, deriva la clave, y desencripta el texto.
|
||||
"""
|
||||
salt = token_con_salt[:16]
|
||||
token = token_con_salt[16:]
|
||||
key = cls._derivar_clave(password, salt)
|
||||
fernet = Fernet(key)
|
||||
return fernet.decrypt(token).decode('utf-8')
|
||||
@@ -0,0 +1,67 @@
|
||||
import uuid
|
||||
import datetime
|
||||
import re
|
||||
|
||||
class GeneradorIDUnico:
|
||||
def __init__(self, tipo_objeto: str):
|
||||
if not re.match(r'^[A-Z]{4}$', tipo_objeto):
|
||||
raise ValueError("El tipo de objeto debe tener 4 letras en mayúscula (ej: ABCD)")
|
||||
self.tipo_objeto = tipo_objeto
|
||||
|
||||
def generar(self):
|
||||
f = datetime.datetime.now().strftime('%Y%m%d')
|
||||
u = uuid.uuid4().hex[:9]
|
||||
n = ''.join(filter(str.isdigit, uuid.uuid4().hex))[:8]
|
||||
n = n.ljust(8, '0')
|
||||
t = sum(int(c) for c in f)
|
||||
t += sum(int(c, 16) for c in u)
|
||||
t += sum(int(c) for c in n)
|
||||
c = str(t % 10)
|
||||
l = t + int(c)
|
||||
d = hex(l % 16)[-1]
|
||||
|
||||
return f"{self.tipo_objeto}{f}-{d}{u}{c}{n}"
|
||||
|
||||
|
||||
@staticmethod
|
||||
def verificar(id_: str) -> bool:
|
||||
try:
|
||||
f = id_[4:12]
|
||||
cuerpo = id_[13:]
|
||||
d = cuerpo[0]
|
||||
u = cuerpo[1:10]
|
||||
c = cuerpo[10]
|
||||
n = cuerpo[11:19]
|
||||
t = sum(int(c) for c in f)
|
||||
t += sum(int(c, 16) for c in u)
|
||||
t += sum(int(c) for c in n)
|
||||
esd = str(t % 10)
|
||||
l = t + int(esd)
|
||||
esh = hex(l % 16)[-1]
|
||||
return (
|
||||
d.lower() == esh.lower() and
|
||||
c == esd
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def tamaño_bytes_bits(id_: str):
|
||||
"""Devuelve el tamaño del ID en bytes y bits (UTF-8)"""
|
||||
bytes_ = len(id_.encode('utf-8'))
|
||||
return bytes_, bytes_ * 8
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ejemplo de uso
|
||||
generador = GeneradorIDUnico("FACT")
|
||||
nuevo_id = generador.generar()
|
||||
print(f"Nuevo ID generado: {nuevo_id}")
|
||||
|
||||
# Verificación del ID
|
||||
es_valido = GeneradorIDUnico.verificar(nuevo_id)
|
||||
print(f"El ID es válido: {es_valido}")
|
||||
|
||||
# Tamaño del ID
|
||||
bytes_, bits_ = GeneradorIDUnico.tamaño_bytes_bits(nuevo_id)
|
||||
print(f"Tamaño del ID: {bytes_} bytes / {bits_} bits")
|
||||
@@ -0,0 +1,59 @@
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
from domains.Llms.Embedders.Base_Embedder import EmbedderABC # Asegúrate de que esta ruta sea correcta
|
||||
from typing import List, Optional
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from sqlalchemy import MetaData # Asegúrate de importar esto
|
||||
from domains.TextManager.notas_mmr import generar_tabla_nota_para_biblioteca # Ajusta si es necesario
|
||||
from sqlalchemy import inspect
|
||||
from domains.base import Base
|
||||
|
||||
|
||||
class Biblioteca:
|
||||
def __init__(
|
||||
self,
|
||||
nombre: str,
|
||||
descripcion: str = "",
|
||||
id: Optional[str] = None,
|
||||
embedder: Optional[EmbedderABC] = None,
|
||||
vector_dim: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Clase que representa una biblioteca de notas de texto.
|
||||
|
||||
:param nombre: Nombre de la biblioteca.
|
||||
:param descripcion: Breve descripción de la biblioteca.
|
||||
:param id: ID único opcional. Si no se proporciona, se genera automáticamente.
|
||||
:param embedder: Objeto que implementa EmbedderABC para generar el vector del nombre.
|
||||
:param vector_dim: Dimensión del vector si no se proporciona un embedder.
|
||||
"""
|
||||
self.id = id if id is not None else GeneradorIDUnico("BBLI").generar()
|
||||
self.nombre = nombre if "biblio" in nombre else f"biblio_{nombre}"
|
||||
self.descripcion = descripcion
|
||||
self.embedder = embedder
|
||||
|
||||
if self.embedder is not None:
|
||||
self.vector_dim = self.embedder.dimension_number()
|
||||
elif vector_dim is not None:
|
||||
self.vector_dim = vector_dim
|
||||
else:
|
||||
raise ValueError("Debes proporcionar un 'embedder' o un 'vector_dim' explícito.")
|
||||
|
||||
def generar_modelo_notas(self, conexion: ConexionBase):
|
||||
nombre_tabla = f"{self.nombre}"
|
||||
print(f"[Notas] Generando tabla: {nombre_tabla}")
|
||||
|
||||
engine = conexion.get_engine()
|
||||
inspector = inspect(engine)
|
||||
|
||||
if inspector.has_table(nombre_tabla):
|
||||
print(f"[Notas] ❌ Ya existe la tabla {nombre_tabla}")
|
||||
raise ValueError(f"Ya existe una tabla con el nombre '{nombre_tabla}' en la base de datos.")
|
||||
|
||||
print("[Notas] Generando definición SQL...")
|
||||
tabla, NotaModel = generar_tabla_nota_para_biblioteca(nombre_tabla, self.vector_dim, Base.metadata)
|
||||
|
||||
print("[Notas] Creando tabla en base de datos...")
|
||||
Base.metadata.create_all(engine)
|
||||
print("[Notas] ✔️ Tabla creada")
|
||||
|
||||
return NotaModel
|
||||
@@ -0,0 +1,112 @@
|
||||
import os
|
||||
import base64
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Column, String, Integer
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from domains.base import Base
|
||||
from domains.Security.Encriptar import Encriptar_fernet
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
from domains.Llms.Embedders.Base_Embedder import EmbedderABC
|
||||
from domains.TextManager.biblioteca import Biblioteca # Suponiendo que defines la clase lógica Biblioteca aquí
|
||||
|
||||
# ----------------------
|
||||
# Cargar clave maestra
|
||||
# ----------------------
|
||||
from entrypoint import ENV_PATH
|
||||
load_dotenv(ENV_PATH)
|
||||
pssword = os.getenv('MASTER_PASSWORD')
|
||||
if pssword is None:
|
||||
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class BibliotecaModel(Base, Model_base):
|
||||
__tablename__ = "bibliotecas"
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
nombre = Column(String, nullable=False, unique=True)
|
||||
|
||||
descripcion = Column(String, nullable=False, default="")
|
||||
|
||||
vector_dim = Column(Integer, nullable=False)
|
||||
|
||||
embedder_info = Column(String, nullable=True) # Nombre de clase, ID de configuración, o info encriptada del embedder
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class BibliotecaMapper(Mapper_base[Biblioteca, BibliotecaModel]):
|
||||
|
||||
@staticmethod
|
||||
def to_model(obj: Biblioteca) -> BibliotecaModel:
|
||||
return BibliotecaModel(
|
||||
id=obj.id,
|
||||
nombre=obj.nombre,
|
||||
descripcion=obj.descripcion,
|
||||
vector_dim=obj.vector_dim
|
||||
# embedder no se serializa en el modelo, se maneja por separado
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: BibliotecaModel) -> Biblioteca:
|
||||
return Biblioteca(
|
||||
id=model.id,
|
||||
nombre=model.nombre,
|
||||
descripcion=model.descripcion,
|
||||
vector_dim=model.vector_dim,
|
||||
embedder=None # se deja para inyección posterior si es necesario
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: Biblioteca) -> dict:
|
||||
embedder_info = type(obj.embedder).__name__ if obj.embedder else None
|
||||
return {
|
||||
"id": obj.id,
|
||||
"nombre": obj.nombre,
|
||||
"descripcion": obj.descripcion,
|
||||
"vector_dim": obj.vector_dim,
|
||||
"embedder_info": embedder_info
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> Biblioteca:
|
||||
return Biblioteca(
|
||||
id=data["id"],
|
||||
nombre=data["nombre"],
|
||||
descripcion=data["descripcion"],
|
||||
vector_dim=data["vector_dim"],
|
||||
embedder=None # inyección manual si se desea
|
||||
)
|
||||
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class BibliotecaRepo(Repo_base[BibliotecaModel, Biblioteca]):
|
||||
def __init__(self, conexion: ConexionBase):
|
||||
super().__init__(
|
||||
session=conexion.get_session(),
|
||||
modelo=BibliotecaModel,
|
||||
mapper=BibliotecaMapper
|
||||
)
|
||||
|
||||
def add(self, biblioteca: Biblioteca, created_by: str = None, notes: str = None) -> str:
|
||||
# Lógica personalizada: prevenir duplicados por nombre
|
||||
existente = self.session.query(self.Modelo).filter_by(nombre=biblioteca.nombre).first()
|
||||
if existente:
|
||||
raise ValueError(f"Ya existe una biblioteca con el nombre '{biblioteca.nombre}'")
|
||||
|
||||
return super().add(biblioteca, created_by=created_by, notes=notes)
|
||||
|
||||
def get_by_nombre(self, nombre: str) -> Biblioteca | None:
|
||||
model = self.session.query(self.Modelo).filter_by(nombre=nombre, sys_deleted_at=None).first()
|
||||
return self.Mapper.from_model(model) if model else None
|
||||
@@ -0,0 +1,41 @@
|
||||
from domains.Security.GenerarIDs import GeneradorIDUnico
|
||||
from typing import List
|
||||
|
||||
class Nota:
|
||||
def __init__(
|
||||
self,
|
||||
titulo: str,
|
||||
tags: List[str] = None,
|
||||
conexiones: List[str] = None,
|
||||
texto: str = "",
|
||||
vector: List[float] = None,
|
||||
resumen: str = "",
|
||||
vector_resumen: List[float] = None,
|
||||
id: str = None
|
||||
):
|
||||
"""
|
||||
Clase que representa una nota de texto con estructura semántica.
|
||||
|
||||
:param titulo: Título de la nota.
|
||||
:param tags: Lista de etiquetas asociadas.
|
||||
:param conexiones: Lista de identificadores relacionados.
|
||||
:param vector: Embedding vectorial de la nota.
|
||||
:param resumen: Texto resumen de la nota.
|
||||
:param vector_resumen: Embedding del resumen.
|
||||
:param id: Identificador único (si no se proporciona, se genera automáticamente).
|
||||
"""
|
||||
self.id = id if id is not None else GeneradorIDUnico("NOTA").generar()
|
||||
self.titulo = titulo
|
||||
self.tags = tags if tags is not None else []
|
||||
self.conexiones = conexiones if conexiones is not None else []
|
||||
self.texto = texto
|
||||
self.vector = vector
|
||||
self.resumen = resumen
|
||||
self.vector_resumen = vector_resumen
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Nota id={self.id}, titulo='{self.titulo}', tags={len(self.tags)}, "
|
||||
f"conexiones={len(self.conexiones)}, vector_dim={len(self.vector)}, "
|
||||
f"resumen_len={len(self.resumen)}, vector_resumen_dim={len(self.vector_resumen)}>"
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import Table, Column, String, Text, MetaData
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.orm import registry, Session
|
||||
from domains.TextManager.nota import Nota
|
||||
from domains.ConexionSql.Base_conexion import ConexionBase
|
||||
from typing import Tuple
|
||||
import re
|
||||
|
||||
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
|
||||
from domains.Credenciales.postgres_credencial import PostgresCredencial # Asegúrate de tener esta clase implementada correctamente
|
||||
|
||||
|
||||
titulo = os.getenv('DB_TITLE')
|
||||
usuario = os.getenv('DB_USER')
|
||||
passwrd = os.getenv('DB_PASSWORD')
|
||||
host = os.getenv('DB_HOST')
|
||||
port = os.getenv('DB_PORT')
|
||||
db_name = os.getenv('DB_NAME')
|
||||
|
||||
|
||||
db_credencial = PostgresCredencial(
|
||||
titulo=titulo,
|
||||
user=usuario,
|
||||
password=passwrd,
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=db_name
|
||||
)
|
||||
|
||||
# from entrypoint.init_db import db_credencial
|
||||
from domains.Logger.logger_db import LoggerDB, logger
|
||||
LoggerDB(db_credencial, "logger_textos", created_by="sistema")
|
||||
|
||||
|
||||
from domains.base import Base # Este es tu declarative_base()
|
||||
|
||||
# ----------------------
|
||||
# Cargar .env
|
||||
# ----------------------
|
||||
from entrypoint import ENV_PATH
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
# ----------------------
|
||||
# REGISTRO DINÁMICO PARA TABLAS
|
||||
# ----------------------
|
||||
mapper_registry = registry()
|
||||
|
||||
# ----------------------
|
||||
# FUNCIONES AUXILIARES
|
||||
# ----------------------
|
||||
|
||||
|
||||
def generar_tabla_nota_para_biblioteca(biblioteca_nombre: str, vector_dim: int, metadata: MetaData) -> Tuple[Table, type]:
|
||||
"""
|
||||
Genera una tabla dinámica y modelo ORM para una biblioteca dada, con campos vectoriales y campos del sistema.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Generando tabla para biblioteca: '{biblioteca_nombre}' con dimensión de vector: {vector_dim}")
|
||||
|
||||
# Nombre SQL-safe
|
||||
nombre_tabla = re.sub(r"[^a-zA-Z0-9_]", "_", biblioteca_nombre.strip().lower())
|
||||
logger.debug(f"Nombre de tabla SQL-safe: '{nombre_tabla}'")
|
||||
|
||||
# Modelo ORM dinámico
|
||||
class NotaModel(Base, Model_base):
|
||||
__tablename__ = nombre_tabla
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
titulo = Column(String, nullable=False)
|
||||
tags = Column(String)
|
||||
conexiones = Column(String)
|
||||
texto = Column(Text)
|
||||
resumen = Column(Text)
|
||||
vector = Column(Vector(vector_dim), nullable=True)
|
||||
vector_resumen = Column(Vector(vector_dim), nullable=True)
|
||||
|
||||
logger.info(f"Modelo ORM 'NotaModel' creado para la tabla '{nombre_tabla}'")
|
||||
logger.debug(f"Columnas del modelo: {[c.name for c in NotaModel.__table__.columns]}")
|
||||
logger.debug(f"Tipos de columnas: {[str(c.type) for c in NotaModel.__table__.columns]}")
|
||||
logger.debug(f"Claves primarias: {[c.name for c in NotaModel.__table__.primary_key]}")
|
||||
|
||||
return NotaModel.__table__, NotaModel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error al generar la tabla y modelo ORM para '{biblioteca_nombre}': {e}")
|
||||
raise
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class NotaMapper(Mapper_base[Nota, object]): # Usa `object` si el modelo es dinámico
|
||||
@staticmethod
|
||||
def to_model(nota: Nota):
|
||||
return {
|
||||
"id": nota.id,
|
||||
"titulo": nota.titulo,
|
||||
"tags": ",".join(nota.tags),
|
||||
"conexiones": ",".join(nota.conexiones),
|
||||
"texto": nota.texto,
|
||||
"resumen": nota.resumen,
|
||||
"vector": nota.vector,
|
||||
"vector_resumen": nota.vector_resumen
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_model(model) -> Nota:
|
||||
return Nota(
|
||||
id=model.id,
|
||||
titulo=model.titulo,
|
||||
tags=model.tags.split(",") if model.tags else [],
|
||||
conexiones=model.conexiones.split(",") if model.conexiones else [],
|
||||
texto=model.texto,
|
||||
resumen=model.resumen,
|
||||
vector=list(model.vector) if model.vector is not None else None,
|
||||
vector_resumen=list(model.vector_resumen) if model.vector_resumen is not None else None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(nota: Nota) -> dict:
|
||||
return {
|
||||
"id": nota.id,
|
||||
"titulo": nota.titulo,
|
||||
"tags": ",".join(nota.tags),
|
||||
"conexiones": ",".join(nota.conexiones),
|
||||
"texto": nota.texto,
|
||||
"resumen": nota.resumen,
|
||||
"vector": nota.vector,
|
||||
"vector_resumen": nota.vector_resumen
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> Nota:
|
||||
return Nota(
|
||||
id=data["id"],
|
||||
titulo=data["titulo"],
|
||||
tags=data["tags"].split(",") if data.get("tags") else [],
|
||||
conexiones=data["conexiones"].split(",") if data.get("conexiones") else [],
|
||||
texto=data["texto"],
|
||||
resumen=data["resumen"],
|
||||
vector=data.get("vector"),
|
||||
vector_resumen=data.get("vector_resumen")
|
||||
)
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class NotaRepo(Repo_base):
|
||||
def __init__(self, session: Session, modelo_nota: type):
|
||||
if modelo_nota is None:
|
||||
raise ValueError("No se puede instanciar NotaRepo sin un modelo válido de nota.")
|
||||
super().__init__(session=session, modelo=modelo_nota, mapper=NotaMapper)
|
||||
|
||||
# ------------------------
|
||||
# Métodos personalizados
|
||||
# ------------------------
|
||||
|
||||
def get_by_tag(self, tag: str) -> list[Nota]:
|
||||
query = self.session.query(self.Modelo).filter(self.Modelo.tags.contains([tag]))
|
||||
models = query.all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
def search_by_text(self, texto: str) -> list[Nota]:
|
||||
query = self.session.query(self.Modelo).filter(self.Modelo.texto.ilike(f'%{texto}%'))
|
||||
models = query.all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
def get_paginated(self, offset: int = 0, limit: int = 10) -> list[Nota]:
|
||||
models = self.session.query(self.Modelo).offset(offset).limit(limit).all()
|
||||
return self.Mapper.from_model_list(models)
|
||||
|
||||
def update(self, id_: str, nota_actualizada: Nota) -> bool:
|
||||
model = self.session.get(self.Modelo, id_)
|
||||
if not model:
|
||||
return False
|
||||
|
||||
# Campos de dominio
|
||||
model.titulo = nota_actualizada.titulo
|
||||
model.texto = nota_actualizada.texto
|
||||
model.tags = nota_actualizada.tags
|
||||
model.conexiones = nota_actualizada.conexiones
|
||||
model.resumen = nota_actualizada.resumen
|
||||
|
||||
# Actualización de campos de sistema
|
||||
model.sys_version = (model.sys_version or 1) + 1
|
||||
|
||||
self.session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
class Usuario:
|
||||
def __init__(self, id: int, nombre: str, email: str, activo: bool = True):
|
||||
self.id = id
|
||||
self.nombre = nombre
|
||||
self.email = email
|
||||
self.activo = activo
|
||||
|
||||
def activar(self):
|
||||
self.activo = True
|
||||
|
||||
def desactivar(self):
|
||||
self.activo = False
|
||||
|
||||
def __repr__(self):
|
||||
return f"Usuario(id={self.id}, nombre='{self.nombre}', email='{self.email}', activo={self.activo})"
|
||||
@@ -0,0 +1,82 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from domains.ArquitectureLayer.Model import Model_base
|
||||
from domains.ArquitectureLayer.Mapper import Mapper_base
|
||||
from domains.ArquitectureLayer.Repo import Repo_base
|
||||
from domains.Usuario.usuario import Usuario
|
||||
|
||||
# ----------------------
|
||||
# MODELO (SQLAlchemy)
|
||||
# ----------------------
|
||||
|
||||
class UsuarioModel(Model_base):
|
||||
__tablename__ = 'usuarios'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
nombre = Column(String, nullable=False)
|
||||
email = Column(String, unique=True, nullable=False)
|
||||
activo = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# ----------------------
|
||||
# MAPPER
|
||||
# ----------------------
|
||||
|
||||
class UsuarioMapper(Mapper_base[Usuario, UsuarioModel]):
|
||||
@staticmethod
|
||||
def to_model(obj: Usuario) -> UsuarioModel:
|
||||
return UsuarioModel(
|
||||
id=obj.id,
|
||||
nombre=obj.nombre,
|
||||
email=obj.email,
|
||||
activo=obj.activo
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model(model: UsuarioModel) -> Usuario:
|
||||
return Usuario(
|
||||
id=model.id,
|
||||
nombre=model.nombre,
|
||||
email=model.email,
|
||||
activo=model.activo
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_dict(obj: Usuario) -> dict:
|
||||
return {
|
||||
'id': obj.id,
|
||||
'nombre': obj.nombre,
|
||||
'email': obj.email,
|
||||
'activo': obj.activo
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> Usuario:
|
||||
return Usuario(
|
||||
id=data['id'],
|
||||
nombre=data['nombre'],
|
||||
email=data['email'],
|
||||
activo=data.get('activo', True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_model_list(models: list[UsuarioModel]) -> list[Usuario]:
|
||||
return [UsuarioMapper.from_model(m) for m in models]
|
||||
|
||||
# ----------------------
|
||||
# REPO
|
||||
# ----------------------
|
||||
|
||||
class UsuarioRepo(Repo_base[UsuarioModel, Usuario]):
|
||||
def __init__(self, session):
|
||||
super().__init__(
|
||||
session=session,
|
||||
modelo=UsuarioModel,
|
||||
mapper=UsuarioMapper
|
||||
)
|
||||
|
||||
def get_by_email(self, email: str) -> Usuario | None:
|
||||
model = (
|
||||
self.session.query(self.Modelo)
|
||||
.filter_by(email=email, sys_deleted_at=None)
|
||||
.first()
|
||||
)
|
||||
return self.Mapper.from_model(model) if model else None
|
||||
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
Reference in New Issue
Block a user