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:
2025-06-21 02:01:21 +02:00
parent 3d5deef0fb
commit aef8791151
101 changed files with 169 additions and 166 deletions
+443
View File
@@ -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]
+13
View File
@@ -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
+32
View File
@@ -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
]
+100
View File
@@ -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()
+56
View File
@@ -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
+48
View File
@@ -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")
View File
+36
View File
@@ -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()
+30
View File
@@ -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
+67
View File
@@ -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
+90
View File
@@ -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
+122
View File
@@ -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]
View File
View File