In [1]:
from dotenv import load_dotenv
import os

# Cargamos las variables de entorno desde el archivo .env.
# OpenRouter nos permite usar distintos modelos con una sola API key.
load_dotenv()

OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"

assert OPENROUTER_API_KEY, "Falta OPENROUTER_API_KEY en el archivo .env"
assert TAVILY_API_KEY, "Falta TAVILY_API_KEY en el archivo .env"

1. Herramientas del agente¶

Vamos a definir tres herramientas:

  1. web_search: busca información en internet usando Tavily.
  2. bananacoin_quote: calcula una cotización simulada de BananoCoin.
  3. simulate_trade: simula una compra de BananoCoins según el capital y el perfil de riesgo.

La primera herramienta conecta el agente con el exterior. Las otras dos representan lógica de negocio propia de nuestro dominio.

In [2]:
from typing import Any, Dict, Literal
from langchain.tools import tool
from tavily import TavilyClient

# Tavily se usa como motor de búsqueda web.
# En artículos anteriores ya lo usamos para que el agente no dependa solo de su conocimiento interno.
tavily_client = TavilyClient(api_key=TAVILY_API_KEY)


@tool
def web_search(query: str) -> Dict[str, Any]:
    """
    Busca en la web información actualizada.

    Úsala para buscar precios reales o aproximados, por ejemplo:
    - Precio actual de Bitcoin en USD.
    - Precio del plátano por kilogramo en USD.
    - Noticias financieras generales.

    Devuelve resultados de búsqueda de Tavily.
    """
    # Limitamos el número de resultados para no saturar el contexto del modelo.
    return tavily_client.search(query=query, max_results=3)


@tool
def bananacoin_quote(
    btc_usd: float,
    banana_usd_per_kg: float,
    banana_weight_kg: float = 0.12,
) -> Dict[str, Any]:
    """
    Calcula una cotización simulada de BananoCoin (BAN).

    BananoCoin es una moneda ficticia cuya fórmula de precio es:

    1 BAN = 1e-6 BTC + 0.10 plátano

    Parámetros:
    - btc_usd: precio de Bitcoin en USD.
    - banana_usd_per_kg: precio del plátano en USD por kilogramo.
    - banana_weight_kg: peso promedio de un plátano en kg. Por defecto 0.12 kg.

    Devuelve:
    - Precio de BAN en USD.
    - Precio de BAN en plátanos.
    - Otros datos intermedios.
    """
    if btc_usd < 0:
        raise ValueError("btc_usd no puede ser negativo.")

    if banana_usd_per_kg <= 0:
        raise ValueError("banana_usd_per_kg debe ser mayor que 0.")

    if banana_weight_kg <= 0:
        raise ValueError("banana_weight_kg debe ser mayor que 0.")

    # Precio de un plátano individual en USD.
    banana_usd_each = banana_usd_per_kg * banana_weight_kg

    # Componente "tipo Bitcoin": una fracción diminuta del precio de BTC.
    btc_component = btc_usd * 1e-6

    # Componente "plátano": 0.10 plátanos por cada BananoCoin.
    banana_component = 0.10 * banana_usd_each

    # Precio total simulado de BananoCoin en USD.
    ban_usd = btc_component + banana_component

    # Expresamos el precio de BananoCoin en plátanos.
    ban_price_in_bananas = ban_usd / banana_usd_each

    return {
        "ticker": "BAN",
        "formula": "1 BAN = 1e-6 BTC + 0.10 plátano",
        "btc_usd": round(float(btc_usd), 2),
        "banana_usd_per_kg": round(float(banana_usd_per_kg), 4),
        "banana_weight_kg": round(float(banana_weight_kg), 4),
        "banana_usd_each": round(float(banana_usd_each), 6),
        "ban_usd": round(float(ban_usd), 6),
        "ban_price_in_bananas": round(float(ban_price_in_bananas), 4),
        "disclaimer": "Cotización simulada con fines educativos. No es un precio real de mercado.",
    }


@tool
def simulate_trade(
    capital_bananas: float,
    ban_price_in_bananas: float,
    risk_level: Literal["conservador", "moderado", "agresivo"] = "moderado",
) -> Dict[str, Any]:
    """
    Simula una compra de BananoCoins usando capital expresado en plátanos.

    Parámetros:
    - capital_bananas: capital total disponible en plátanos.
    - ban_price_in_bananas: precio de cada BananoCoin en plátanos.
    - risk_level: perfil de riesgo. Puede ser "conservador", "moderado" o "agresivo".

    Reglas simuladas:
    - Conservador: usa 5% del capital.
    - Moderado: usa 10% del capital.
    - Agresivo: usa 20% del capital.
    - Se aplica una comisión simulada de 0.5%.

    Devuelve un diccionario con la simulación de la orden.
    """
    risk_factors = {
        "conservador": 0.05,
        "moderado": 0.10,
        "agresivo": 0.20,
    }

    if capital_bananas <= 0:
        raise ValueError("capital_bananas debe ser mayor que 0.")

    if ban_price_in_bananas <= 0:
        raise ValueError("ban_price_in_bananas debe ser mayor que 0.")

    # Normalizamos el perfil de riesgo.
    # Si el agente no pasa un valor válido, usamos "moderado" como valor por defecto.
    risk = str(risk_level or "moderado").strip().lower()
    fraction = risk_factors.get(risk, 0.10)

    fee_rate = 0.005  # Comisión simulada de 0.5%.

    capital_to_use = float(capital_bananas) * fraction
    fee = capital_to_use * fee_rate
    net_capital = capital_to_use - fee

    amount_ban = net_capital / float(ban_price_in_bananas)

    return {
        "action": "buy",
        "ticker": "BAN",
        "risk_level": risk,
        "capital_bananas": round(float(capital_bananas), 4),
        "fraction_used": fraction,
        "capital_to_use_bananas": round(capital_to_use, 4),
        "fee_bananas": round(fee, 4),
        "net_capital_bananas": round(net_capital, 4),
        "ban_price_in_bananas": round(float(ban_price_in_bananas), 4),
        "estimated_amount_ban": round(amount_ban, 6),
        "disclaimer": "Simulación educativa. No es una orden real de compra.",
    }

Prueba local de las herramientas¶

Antes de dárselas al agente, podemos probarlas directamente. Esto es útil para verificar que la lógica de negocio funciona incluso antes de meter el modelo de lenguaje en medio.

In [3]:
from pprint import pprint

quote_local = bananacoin_quote.invoke(
    {
        "btc_usd": 118_000,
        "banana_usd_per_kg": 0.35,
    }
)

pprint(quote_local)

trade_local = simulate_trade.invoke(
    {
        "capital_bananas": 1_000,
        "ban_price_in_bananas": quote_local["ban_price_in_bananas"],
        "risk_level": "moderado",
    }
)

pprint(trade_local)
{'ban_price_in_bananas': 2.9095,
 'ban_usd': 0.1222,
 'banana_usd_each': 0.042,
 'banana_usd_per_kg': 0.35,
 'banana_weight_kg': 0.12,
 'btc_usd': 118000.0,
 'disclaimer': 'Cotización simulada con fines educativos. No es un precio real '
               'de mercado.',
 'formula': '1 BAN = 1e-6 BTC + 0.10 plátano',
 'ticker': 'BAN'}
{'action': 'buy',
 'ban_price_in_bananas': 2.9095,
 'capital_bananas': 1000.0,
 'capital_to_use_bananas': 100.0,
 'disclaimer': 'Simulación educativa. No es una orden real de compra.',
 'estimated_amount_ban': 34.198316,
 'fee_bananas': 0.5,
 'fraction_used': 0.1,
 'net_capital_bananas': 99.5,
 'risk_level': 'moderado',
 'ticker': 'BAN'}

2. Agente con herramientas y memoria¶

Ahora creamos el agente principal.

Usaremos:

  • ChatOpenAI apuntando a OpenRouter.
  • create_agent para crear el agente.
  • InMemorySaver como checkpointer de memoria.
  • thread_id para separar conversaciones.

El sistema le indica al agente que es BananoTrader, un trader simulado de BananoCoin.

In [4]:
from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

# Modelo principal para razonar y usar herramientas.
# gpt-4o-mini es suficiente para este ejercicio y barato para pruebas.
model = ChatOpenAI(
    model="openai/gpt-4o-mini",
    api_key=OPENROUTER_API_KEY,
    base_url=OPENROUTER_BASE_URL,
    temperature=0,
)

# Memoria de corto plazo.
# InMemorySaver guarda el estado mientras viva el proceso de Python.
# Si reinicias el kernel, se pierde el historial.
memory = InMemorySaver()

system_prompt = """
Eres BananoTrader, un agente trader simulado de BananoCoin (BAN), una moneda digital ficticia basada en plátanos.

Tu trabajo es ayudar al usuario a entender una cotización simulada de BananoCoin y simular decisiones de compra.

Reglas importantes:

1. Recuerda los datos del usuario si aparecen en la conversación: nombre, perfil de riesgo y capital en plátanos.
2. Si faltan datos importantes, pídelos de forma breve.
3. Usa web_search para buscar:
   - Precio actual de Bitcoin en USD.
   - Precio del plátano por kilogramo en USD.
4. Si la búsqueda web no devuelve cifras claras, usa estos valores de respaldo:
   - BTC: 118000 USD.
   - Plátano: 0.35 USD por kg.
5. Usa bananacoin_quote para calcular el precio simulado de BananoCoin.
6. Usa simulate_trade para simular una compra según el capital y el perfil de riesgo del usuario.
7. Responde siempre en español, con un tono claro, didáctico y ligeramente divertido.
8. Recuerda siempre que esto es una simulación educativa y que no constituye asesoría financiera real.
9. No inventes precios si puedes buscarlos, pero si los resultados web son ambiguos, indícalo explícitamente.
10. Cuando resumes una operación, incluye:
   - Datos usados.
   - Precio de BAN en plátanos.
   - Cantidad estimada de BAN comprados.
   - Advertencia de que es una simulación.
"""

agent = create_agent(
    model=model,
    tools=[web_search, bananacoin_quote, simulate_trade],
    system_prompt=system_prompt,
    checkpointer=memory,
)

Helper para conversar con el agente¶

Creamos una pequeña función para no repetir el código de invocación en cada turno.

Nota didáctica: Hasta ahora, response['messages'][-1].content devolvía un string simple. Sin embargo, al trabajar con modelos multimodales o respuestas complejas, el contenido puede venir como una lista de partes (texto, imágenes, etc.). La función extract_text nos asegura que siempre extraigamos el texto legible sin que el código rompa.

In [5]:
from langchain_core.messages import HumanMessage


def extract_text(content: Any) -> str:
    """
    Extrae texto legible de la respuesta del modelo.

    Algunos modelos pueden devolver contenido como string o como lista de partes.
    Esta función normaliza ambos casos.
    """
    if isinstance(content, str):
        return content

    if isinstance(content, list):
        parts = []
        for item in content:
            if isinstance(item, dict):
                if item.get("type") == "text" and "text" in item:
                    parts.append(str(item["text"]))
                elif "text" in item:
                    parts.append(str(item["text"]))
            else:
                parts.append(str(item))
        return "\n".join(parts)

    return str(content)


def chat(text: str, config: Dict[str, Any]) -> str:
    """
    Envía un mensaje al agente y devuelve el texto de la última respuesta.
    """
    response = agent.invoke(
        {"messages": [HumanMessage(content=text)]},
        config,
    )
    return extract_text(response["messages"][-1].content)

3. Memoria: el agente recuerda quién eres¶

Vamos a crear una conversación con thread_id fijo. Mientras usemos el mismo thread_id, el agente debería recordar el contexto.

In [6]:
config_sergio = {"configurable": {"thread_id": "banano-sergio-1"}}

print(
    chat(
        "Hola, me llamo Sergio. Mi perfil de riesgo es moderado y tengo 1000 plátanos disponibles para invertir.",
        config_sergio,
    )
)
Aquí tienes el resumen de tu operación simulada de compra de BananoCoins (BAN):

- **Capital total disponible**: 1000 plátanos.
- **Porcentaje utilizado (10%)**: 100 plátanos.
- **Comisión simulada (0.5%)**: 0.5 plátanos.
- **Capital neto utilizado**: 99.5 plátanos.
- **Precio de 1 BAN en plátanos**: aproximadamente **0.7706 plátanos**.
- **Cantidad estimada de BAN comprados**: aproximadamente **129.12 BAN**.

Recuerda que esto es una **simulación educativa** y no constituye una orden real de compra. Si tienes más preguntas o deseas realizar otra simulación, ¡estaré encantado de ayudarte! 🍌💰
In [7]:
print(
    chat(
        "¿Qué datos míos recuerdas?",
        config_sergio,
    )
)
Recuerdo que te llamas **Sergio**, tienes un **perfil de riesgo moderado** y cuentas con **1000 plátanos** disponibles para invertir. Si necesitas más información o quieres hacer otra simulación, ¡aquí estoy para ayudarte! 🍌

4. Web search + tools: cotización y simulación de compra¶

Ahora le pedimos al agente que busque precios reales o aproximados en internet, calcule la cotización de BananoCoin y simule una compra.

Aquí es donde se combinan:

  • Memoria: recuerda el capital y el perfil de Sergio.
  • Web search: busca precios externos.
  • Tools: calcula cotización y simula trade.
In [8]:
print(
    chat(
        """
        Busca en la web el precio actual de Bitcoin en USD y el precio del plátano por kg en USD.

        Luego:
        1. Usa bananacoin_quote para calcular la cotización simulada de BananoCoin.
        2. Usa simulate_trade para simular una compra con mi capital y perfil de riesgo recordados.
        3. Explícame el resultado de forma sencilla.
        """,
        config_sergio,
    )
)
Aquí tienes los resultados de la simulación:

### Datos utilizados:
- **Precio de Bitcoin (BTC)**: 64,380.43 USD.
- **Precio del plátano por kilogramo**: 0.80 USD.
  
### Cotización simulada de BananoCoin (BAN):
- **Precio de 1 BAN**: aproximadamente **0.07398 USD**.
- **Precio de 1 BAN en plátanos**: aproximadamente **0.7706 plátanos**.

### Simulación de compra:
- **Capital total disponible**: 1000 plátanos.
- **Porcentaje utilizado (10%)**: 100 plátanos.
- **Comisión simulada (0.5%)**: 0.5 plátanos.
- **Capital neto utilizado**: 99.5 plátanos.
- **Precio de 1 BAN en plátanos**: aproximadamente **0.7706 plátanos**.
- **Cantidad estimada de BAN comprados**: aproximadamente **129.12 BAN**.

### Explicación sencilla:
Con tu capital de 1000 plátanos y un perfil de riesgo moderado, decidiste invertir el 10% de tu capital, lo que equivale a 100 plátanos. Después de aplicar una pequeña comisión, te quedan 99.5 plátanos para comprar BananoCoins. Dado que cada BAN cuesta aproximadamente 0.7706 plátanos, puedes comprar alrededor de 129 BananoCoins.

Recuerda que esto es una **simulación educativa** y no una orden real de compra. Si tienes más preguntas o deseas hacer otra simulación, ¡aquí estoy para ayudarte! 🍌💰

Nota de honestidad técnica¶

Este agente puede equivocarse al interpretar resultados web. La búsqueda web no sustituye a una API financiera profesional. Además, el precio de BananoCoin es una fórmula ficticia creada para el artículo.

En producción, lo suyo sería:

  • Usar APIs financieras confiables.
  • Validar numéricamente cada dato.
  • Guardar logs de decisiones.
  • Añadir límites de riesgo reales.
  • No ejecutar operaciones automáticas sin supervisión.

5. Aislamiento por thread_id¶

La memoria no es global. Cada thread_id representa una conversación distinta. Vamos a comprobarlo con otro usuario.

In [9]:
config_ana = {"configurable": {"thread_id": "banano-ana-1"}}

print(
    chat(
        "Hola, me llamo Ana. Mi perfil de riesgo es conservador y tengo 250 plátanos disponibles.",
        config_ana,
    )
)
Aquí tienes el resumen de tu simulación de compra de BananoCoins (BAN):

- **Capital total disponible**: 250 plátanos.
- **Porcentaje utilizado (5%)**: 12.5 plátanos.
- **Comisión simulada (0.5%)**: 0.0625 plátanos.
- **Capital neto para la compra**: 12.4375 plátanos.
- **Precio de BAN en plátanos**: aproximadamente **1.6329 plátanos**.
- **Cantidad estimada de BAN comprados**: aproximadamente **7.62 BAN**.

Recuerda que esto es solo una simulación educativa y no constituye una orden real de compra. Si tienes más preguntas o quieres hacer otra simulación, ¡aquí estoy para ayudarte! 🍌📈
In [10]:
print(
    chat(
        "¿Qué capital y perfil de riesgo tengo?",
        config_ana,
    )
)
Tienes un capital de **250 plátanos** y tu perfil de riesgo es **conservador**. Si necesitas más información o quieres realizar otra simulación, ¡no dudes en decírmelo! 🍌💰
In [11]:
# En el hilo de Ana, el agente no debería conocer los datos de Sergio.
print(
    chat(
        "¿Qué datos tiene Sergio en esta conversación?",
        config_ana,
    )
)
No tengo información sobre un usuario llamado Sergio en esta conversación. Solo tengo los datos de **Ana**, que tiene un capital de **250 plátanos** y un perfil de riesgo **conservador**. Si necesitas ayuda con algo más, ¡aquí estoy! 🍌😊

6. Multimodal messages: dictar órdenes por voz¶

Ahora añadimos una opción experimental: grabar audio con el micrófono y enviarlo a un modelo de audio para transcribirlo.

La arquitectura es la siguiente:

  1. Grabamos audio localmente con sounddevice.
  2. Lo enviamos a un modelo de audio vía OpenRouter.
  3. El modelo de audio devuelve una transcripción textual.
  4. Esa transcripción se envía al agente BananoTrader.

Así separamos responsabilidades:

  • El modelo de audio entiende la voz.
  • El agente principal usa herramientas y memoria.

Si no tienes micrófono, la celda no fallará: usará un comando de texto de respaldo.

Nota técnica: El formato de audio en base64 dentro de un mensaje de chat es específico de la API nativa de OpenAI. Dependiendo de cómo OpenRouter o el proveedor final implementen el enrutamiento de audio, esta celda podría requerir ajustes o usar un modelo específico que soporte transcripción directa vía chat. Si falla, el fallback de texto nos salva.

In [12]:
import base64
import io

# Cambia esto a False si no quieres probar audio.
USE_VOICE = True

AUDIO_MODEL = "openai/gpt-audio-mini"


def record_wav(seconds: int = 4, samplerate: int = 16000) -> bytes | None:
    """
    Graba audio desde el micrófono y devuelve un archivo WAV en memoria.

    Si no hay micrófono o falta algún paquete, devuelve None.
    """
    try:
        import sounddevice as sd
        from scipy.io import wavfile

        print(f"Grabando {seconds} segundos... habla ahora.")
        audio = sd.rec(
            int(seconds * samplerate),
            samplerate=samplerate,
            channels=1,
            dtype="int16",
        )
        sd.wait()
        print("Grabación terminada.")

        buffer = io.BytesIO()
        wavfile.write(buffer, samplerate, audio)
        return buffer.getvalue()

    except Exception as e:
        print(f"No se pudo grabar audio: {e}")
        return None


def transcribe_audio(wav_bytes: bytes) -> str:
    """
    Envía audio en formato WAV a un modelo de audio y devuelve la transcripción.
    """
    audio_model = ChatOpenAI(
        model=AUDIO_MODEL,
        api_key=OPENROUTER_API_KEY,
        base_url=OPENROUTER_BASE_URL,
        temperature=0,
    )

    audio_b64 = base64.b64encode(wav_bytes).decode("utf-8")

    # Mensaje multimodal: texto + audio.
    # El formato exacto puede variar según proveedor/modelo.
    # Aquí usamos una estructura compatible con modelos de audio de OpenAI.
    message = HumanMessage(
        content=[
            {
                "type": "text",
                "text": (
                    "Transcribe en español el audio siguiente. "
                    "Si no hay voz clara, responde exactamente: NO_AUDIO."
                ),
            },
            {
                "type": "input_audio",
                "input_audio": {
                    "data": audio_b64,
                    "format": "wav",
                },
            },
        ]
    )

    response = audio_model.invoke([message])
    return extract_text(response.content)
In [ ]:
# Ejecutamos el flujo de voz opcional.
# Si USE_VOICE es False o falla la grabación/transcripción, usamos un comando textual.

voice_command = None

if USE_VOICE:
    wav_bytes = record_wav(seconds=5)

    if wav_bytes is not None:
        try:
            voice_command = transcribe_audio(wav_bytes)
            print("Comando transcrito:")
            print(voice_command)
        except Exception as e:
            print(f"Falló el procesamiento de audio: {e}")
            voice_command = None

# Si no hay comando de voz válido, usamos un comando de respaldo.
if not voice_command or voice_command.strip().upper() == "NO_AUDIO":
    voice_command = (
        "Simula una compra de BananoCoins usando mi capital recordado y mi perfil de riesgo. "
        "Primero busca precios si no los tienes claros."
    )
    print("Usando comando textual de respaldo:")
    print(voice_command)

# Enviamos el comando al agente principal, dentro del hilo de Sergio.
print()
print(chat(voice_command, config_sergio))