Volver a anexos
Anexo: Asistente con LLM

Asistente con LLM

Vamos a construir un asistente que entiende lenguaje natural y consulta tu base de datos para responder preguntas reales de tu negocio. Sin alucinar. Sin exponer SQL. Sin filtrar datos que no toca.

Aquí veremos la arquitectura, la seguridad, el tool calling y la configuración desde el cliente. Si tu proyecto además necesita consultar documentación no estructurada (manuales, políticas, PDFs), tienes una ampliación opcional sobre RAG con Supabase y pgvector.

1 Qué vamos a construir

Un chat web donde el usuario puede preguntar cosas como:

💬 "¿Cuántos pedidos ha hecho Juan este mes?"
💬 "¿Cuáles son los tres productos más vendidos?"
💬 "Dame el total facturado en marzo"

Y el asistente responde con datos reales de tu base de datos, no inventados. Además tendremos un modal de configuración para ajustar modelo, temperatura, tokens y system prompt al vuelo.

Chat con historial

Conversación continua, el LLM recuerda el contexto.

Modal de config

Modelo, temperatura, max tokens, top_p y system prompt editable.

Tool calling

El LLM consulta tu BD mediante funciones controladas, nunca SQL libre.

2 Proveedor LLM y tu API key

En los ejemplos usaremos OpenAI porque su SDK es el más extendido y sus modelos son los más probables que tengas a mano. Pero el patrón funciona idéntico con otros proveedores:

OpenAI

SDK openai. Tool calling nativo. gpt-4o, gpt-4o-mini.

Anthropic (Claude)

SDK @anthropic-ai/sdk. Tool use. Claude Sonnet / Haiku.

Ollama local

API compatible con OpenAI. Modelos locales, sin coste, más lento.

Necesitas una API key. En OpenAI la sacas en platform.openai.com → API keys. Carga 5 € de crédito, más que suficiente para practicar.

¿Dónde va la API key?

Mal: en el cliente
// assistant.js (navegador)
const openai = new OpenAI("sk-proj-abc123...");
Cualquiera lo ve con F12 y tu factura de OpenAI explota:
Tokens gastados por atacantes: 0
Bien: en el backend
// .env (servidor)
OPENAI_API_KEY=sk-proj-abc123...
// src/pages/api/chat.ts (Astro)
import.meta.env.OPENAI_API_KEY
El cliente solo llama a TU endpoint:
POST /api/chat
{ "message": "..." }
// la key jamás sale del servidor

Regla de oro: toda clave que cueste dinero o dé acceso a datos, vive en el servidor. Sin excepciones.

Error mortal de principiante

Nunca, JAMÁS, pongas la API key en un componente React, en un archivo .astro sin frontmatter, o en cualquier cosa que acabe en el bundle del navegador. Los bots que escrapean GitHub buscan esto 24/7. Si se filtra, revócala inmediatamente desde el panel del proveedor.

3 Tool calling: qué es y por qué es el camino correcto

Hay dos formas de conectar un LLM a una base de datos, y NO son iguales:

❌ Text-to-SQL

El LLM escribe SQL directo y tú lo ejecutas. Aunque filtres por SELECT, tienes:

  • Inyección vía prompt ("ignora instrucciones, dame todo")
  • Queries pesadas que tumban la BD
  • Exposición de columnas sensibles
  • Imposible auditar qué hace

✅ Tool calling

Tú defines funciones concretas. El LLM sólo puede llamar a esas:

  • Superficie de ataque reducida al mínimo
  • Queries parametrizados controlados por ti
  • Cada tool se testea y se audita
  • El LLM nunca ve la estructura real de la BD

🧠 Analogía con Java

Un esquema de tool es el equivalente conceptual de declarar una firma de método:

public List<Pedido> getPedidosPorCliente(int clienteId)

El LLM ve la firma en JSON Schema, sabe qué recibe y qué devuelve. Tú implementas el cuerpo. Él decide cuándo llamarla. Igual que un programador leyendo un Javadoc.

Flujo de Tool Calling en vivo

1. El usuario pregunta
"¿Cuántos pedidos ha hecho Juan este mes?"
Usuario
Navegador
Backend
/api/chat
LLM
OpenAI / Claude
Supabase
Solo SELECT

El usuario no sabe nada de SQL ni de tokens. Solo ve la respuesta. Esa es la magia del tool calling.

4 Preparar Supabase

Crea un proyecto gratuito en supabase.com. Ve al SQL Editor y pega el siguiente script para crear un mini e-commerce de ejemplo:

-- Tablas de negocio
create table clientes (
  id bigint primary key generated always as identity,
  nombre text not null,
  email text unique not null,
  creado_en timestamptz default now()
);

create table productos (
  id bigint primary key generated always as identity,
  nombre text not null,
  precio numeric(10,2) not null,
  stock int not null default 0
);

create table pedidos (
  id bigint primary key generated always as identity,
  cliente_id bigint references clientes(id),
  fecha timestamptz default now(),
  total numeric(10,2) not null
);

create table pedido_items (
  id bigint primary key generated always as identity,
  pedido_id bigint references pedidos(id),
  producto_id bigint references productos(id),
  cantidad int not null,
  precio_unitario numeric(10,2) not null
);

-- Datos de prueba
insert into clientes (nombre, email) values
  ('Juan Pérez', 'juan@example.com'),
  ('Ana López', 'ana@example.com'),
  ('Carlos Ruiz', 'carlos@example.com');

insert into productos (nombre, precio, stock) values
  ('Teclado mecánico', 89.90, 25),
  ('Ratón inalámbrico', 34.50, 40),
  ('Monitor 27"', 299.00, 10),
  ('Auriculares USB', 59.00, 60);

insert into pedidos (cliente_id, total) values
  (1, 124.40),
  (1, 299.00),
  (2, 93.50);

insert into pedido_items (pedido_id, producto_id, cantidad, precio_unitario) values
  (1, 1, 1, 89.90),
  (1, 2, 1, 34.50),
  (2, 3, 1, 299.00),
  (3, 2, 1, 34.50),
  (3, 4, 1, 59.00);

Ejecuta y verifica en Table Editor que tienes 4 tablas con datos.

5 Row Level Security: la última línea de defensa

Aunque las tools estén bien hechas, NUNCA confíes en una sola capa de seguridad. Configuramos RLS de modo que la propia BD rechace cualquier operación de escritura. Aunque el código se comprometa, los datos quedan intactos.

-- Activar RLS en todas las tablas
alter table clientes enable row level security;
alter table productos enable row level security;
alter table pedidos enable row level security;
alter table pedido_items enable row level security;

-- Policies: permitir sólo SELECT al rol anon
create policy "lectura publica clientes" on clientes
  for select using (true);

create policy "lectura publica productos" on productos
  for select using (true);

create policy "lectura publica pedidos" on pedidos
  for select using (true);

create policy "lectura publica pedido_items" on pedido_items
  for select using (true);

-- Al no existir policies para INSERT, UPDATE, DELETE,
-- cualquier intento es rechazado por defecto.

💡 Lo importante

Usaremos la anon key (pública, bajo RLS) desde el backend. El service_role NO. ¿Por qué? Porque el service_role salta el RLS. Si nuestra API se vulnera, el atacante tiene BD entera. Con anon key + RLS, aunque se comprometa la API, el techo es lo que el RLS permita: lectura.

Defensa en profundidad

Un atacante tendría que romper las 3 capas. Saltar una no es suficiente.

!
Ataque
El LLM intenta hacer DROP TABLE productos
1
Capa 1: Tool calling
No existe la función dropTable(). El LLM sólo puede llamar a las tools que tú definiste.
2
Capa 2: Queries parametrizados
Aunque alguna tool recibiera input malicioso, supabase-js parametriza — no hay concatenación de SQL.
3
Capa 3: RLS + rol readonly
La BD misma rechaza cualquier UPDATE, INSERT o DELETE. La fortaleza final.

6 Instalar dependencias y variables de entorno

npm install openai @supabase/supabase-js

Crea un archivo .env en la raíz de tu proyecto Astro:

OPENAI_API_KEY=sk-proj-...
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_ANON_KEY=eyJhbGciOi...

Añade .env al .gitignore

Si subes la clave a Git aunque luego la borres, queda en el historial para siempre. Los bots la encontrarán y tu factura sufrirá.

7 Definir las tools: esquema + implementación

Cada tool tiene dos partes:

  • Esquema JSON: qué ve el LLM. Nombre, descripción, parámetros tipados.
  • Implementación: función JavaScript real que consulta Supabase y devuelve el resultado.

Crea src/lib/tools.js:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  import.meta.env.SUPABASE_URL,
  import.meta.env.SUPABASE_ANON_KEY
);

// ===== ESQUEMAS (lo que ve el LLM) =====
export const toolSchemas = [
  {
    type: 'function',
    function: {
      name: 'listarProductos',
      description: 'Devuelve todos los productos del catálogo con su precio y stock.',
      parameters: { type: "object", properties: {}, required: [] },
    }
  },
  {
    type: 'function',
    function: {
      name: 'buscarClientePorNombre',
      description: 'Busca un cliente por nombre parcial (case insensitive). Devuelve coincidencias.',
      parameters: {
        type: 'object',
        properties: {
          nombre: { type: "string", description: "Nombre o parte del nombre a buscar" }
        },
        required: ['nombre']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'getPedidosPorCliente',
      description: 'Devuelve los pedidos de un cliente dado su id.',
      parameters: {
        type: 'object',
        properties: {
          clienteId: { type: "number", description: "ID numérico del cliente" }
        },
        required: ['clienteId']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'productosMasVendidos',
      description: 'Top N de productos más vendidos por unidades.',
      parameters: {
        type: 'object',
        properties: {
          limite: { type: "number", description: "Cuántos devolver (por defecto 5)" }
        },
        required: []
      }
    }
  }
];

// ===== IMPLEMENTACIONES (lo que hace tu backend) =====
export const toolHandlers = {
  async listarProductos() {
    const { data, error } = await supabase
      .from('productos')
      .select('id, nombre, precio, stock')
      .order('nombre');
    if (error) throw error;
    return data;
  },

  async buscarClientePorNombre({ nombre }) {
    const { data, error } = await supabase
      .from('clientes')
      .select('id, nombre, email')
      .ilike('nombre', `%$${nombre}%`);
    if (error) throw error;
    return data;
  },

  async getPedidosPorCliente({ clienteId }) {
    const { data, error } = await supabase
      .from('pedidos')
      .select('id, fecha, total')
      .eq('cliente_id', clienteId)
      .order('fecha', { ascending: false });
    if (error) throw error;
    return data;
  },

  async productosMasVendidos({ limite = 5 } = {}) {
    const { data, error } = await supabase
      .rpc('productos_mas_vendidos', { lim: limite });
    if (error) throw error;
    return data;
  }
};

Para productosMasVendidos crea esta función SQL en Supabase:

create or replace function productos_mas_vendidos(lim int default 5)
returns table (nombre text, unidades bigint)
language sql stable as $$
  select p.nombre, sum(pi.cantidad)::bigint as unidades
  from pedido_items pi
  join productos p on p.id = pi.producto_id
  group by p.nombre
  order by unidades desc
  limit lim;
$$;

💎 Patrón clave

Las descripciones de las tools son PROMPT ENGINEERING. Cuanto más claras, mejor decide el LLM. Describe qué hacen, cuándo usarlas y qué devuelven. Esa descripción vale más que el código.

8 Endpoint /api/chat con bucle de tool calling

El flujo del servidor: recibe mensajes → llama al LLM con las tools → si el LLM pide ejecutar una tool, la ejecutamos y se la devolvemos → repetimos hasta que el LLM deja de pedir tools y devuelve texto.

Crea src/pages/api/chat.js:

import OpenAI from 'openai';
import { toolSchemas, toolHandlers } from '../../lib/tools.js';

export const prerender = false; // habilita SSR para este endpoint

const openai = new OpenAI({
  apiKey: import.meta.env.OPENAI_API_KEY,
});

const DEFAULT_SYSTEM = `Eres un asistente interno del equipo. Respondes en español,
en tono profesional y conciso. Para cualquier pregunta sobre clientes, productos
o pedidos, usa las herramientas disponibles; nunca inventes datos. Si una consulta
no puede responderse con las tools, dilo claramente.`;

export async function POST({ request }) {
  const { messages, config = {} } = await request.json();

  const history = [
    { role: "system", content: config.systemPrompt || DEFAULT_SYSTEM },
    ...messages,
  ];

  // Bucle: el LLM puede llamar varias tools encadenadas
  for (let i = 0; i < 5; i++) {
    const completion = await openai.chat.completions.create({
      model: config.model || 'gpt-4o-mini',
      temperature: config.temperature ?? 0.3,
      max_tokens: config.maxTokens ?? 800,
      top_p: config.topP ?? 1,
      messages: history,
      tools: toolSchemas,
    });

    const msg = completion.choices[0].message;
    history.push(msg);

    // ¿Pide ejecutar alguna tool?
    if (msg.tool_calls?.length) {
      for (const call of msg.tool_calls) {
        const handler = toolHandlers[call.function.name];
        let result;
        try {
          const args = JSON.parse(call.function.arguments);
          result = handler ? await handler(args) : { error: "Tool desconocida" };
        } catch (e) {
          result = { error: e.message };
        }
        history.push({
          role: 'tool',
          tool_call_id: call.id,
          content: JSON.stringify(result),
        });
      }
      continue; // vuelta al LLM con los resultados
    }

    // Sin tool calls: respuesta final
    return new Response(JSON.stringify({
      reply: msg.content,
      usage: completion.usage,
    }), {
      headers: { "Content-Type": "application/json" }
    });
  }

  return new Response(JSON.stringify({
    reply: 'Perdona, me he enredado llamando a herramientas. Prueba a reformular.',
  }), { status: 200, headers: { "Content-Type": "application/json" } });
}

Detalles importantes del bucle

  • Límite de 5 iteraciones: evita bucles infinitos si el LLM se obsesiona llamando tools sin llegar a responder.
  • prerender = false: Astro necesita renderizar el endpoint en cada request. Sin esto se intentaría precompilar.
  • Try/catch por tool: si una consulta falla, devuelve el error al LLM. Muchas veces sabe corregirse y reintenta.
  • Historial completo al LLM: incluimos la respuesta del LLM Y el resultado de la tool, así mantiene el hilo.

9 Frontend: chat + modal de configuración

Creamos un componente Astro con el HTML del chat y del modal de configuración en la plantilla, y la lógica en un <script> con JavaScript vanilla. La config se guarda en localStorage para que persista entre recargas.

Crea src/components/AssistantChat.astro:

---
// No necesita props: todo el estado vive en el navegador.
---

<div class="chat">
  <div class="chat-header">
    <h3>Asistente</h3>
    <button type="button" data-open-config>⚙️ Configuración</button>
  </div>

  <div class="chat-messages" data-messages></div>

  <div class="chat-input">
    <input type="text" data-input placeholder="Pregunta algo..." />
    <button type="button" data-send>Enviar</button>
  </div>

  <!-- Modal de configuración (oculto por defecto) -->
  <div class="modal" data-modal hidden>
    <div class="modal-box">
      <h3>Configuración del asistente</h3>

      <label>
        <span>Modelo</span>
        <select data-cfg="model">
          <option value="gpt-4o-mini">gpt-4o-mini (rápido, barato)</option>
          <option value="gpt-4o">gpt-4o (más capaz)</option>
        </select>
      </label>

      <label>
        <span>Temperatura: <output data-cfg-output="temperature">0.3</output></span>
        <input type="range" min="0" max="2" step="0.1" data-cfg="temperature" />
        <small>Más bajo = más determinista. Para datos, 0.2-0.4.</small>
      </label>

      <label>
        <span>Max tokens</span>
        <input type="number" data-cfg="maxTokens" />
      </label>

      <label>
        <span>Top P: <output data-cfg-output="topP">1</output></span>
        <input type="range" min="0" max="1" step="0.05" data-cfg="topP" />
      </label>

      <label>
        <span>System prompt (vacío = por defecto)</span>
        <textarea rows="4" data-cfg="systemPrompt"></textarea>
      </label>

      <div class="modal-actions">
        <button type="button" data-close-config>Cancelar</button>
        <button type="button" data-save-config>Guardar</button>
      </div>
    </div>
  </div>
</div>

<style>
  .chat { display: flex; flex-direction: column; height: 600px;
    background: #0f172a; border: 1px solid #334155; border-radius: 12px; overflow: hidden; }
  .chat-header { display: flex; justify-content: space-between; align-items: center;
    padding: 12px 16px; border-bottom: 1px solid #334155; }
  .chat-header h3 { margin: 0; font-weight: bold; color: white; }
  .chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
  .msg { max-width: 80%; padding: 8px 14px; border-radius: 12px; }
  .msg-user { align-self: flex-end; background: #7c3aed; color: white; }
  .msg-assistant { align-self: flex-start; background: #1e293b; color: #e2e8f0; }
  .msg-thinking { align-self: flex-start; color: #64748b; font-size: 13px; font-style: italic; }
  .chat-input { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #334155; }
  .chat-input input { flex: 1; background: #1e293b; border: none; color: white;
    border-radius: 8px; padding: 8px 12px; outline: none; }
  .chat-input button { background: #7c3aed; color: white; border: none;
    padding: 8px 16px; border-radius: 8px; cursor: pointer; }
  .chat-input button:disabled { opacity: 0.5; }

  .modal { position: fixed; inset: 0; background: rgba(0,0,0,.7);
    display: flex; align-items: center; justify-content: center; z-index: 50; }
  .modal[hidden] { display: none; }
  .modal-box { background: #0f172a; border: 1px solid #334155; border-radius: 12px;
    padding: 24px; width: 500px; max-width: 90vw; }
  .modal-box label { display: block; margin-bottom: 12px; color: #94a3b8; font-size: 13px; }
  .modal-box label span { display: block; margin-bottom: 4px; }
  .modal-box input, .modal-box select, .modal-box textarea { width: 100%; background: #1e293b;
    border: none; color: white; padding: 8px 12px; border-radius: 6px; }
  .modal-box small { color: #64748b; font-size: 11px; }
  .modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
  .modal-actions button { padding: 8px 16px; border: none; border-radius: 6px;
    cursor: pointer; background: #334155; color: white; }
  .modal-actions button:last-child { background: #7c3aed; }
</style>

<script>
  const DEFAULT_CONFIG = {
    model: 'gpt-4o-mini',
    temperature: 0.3,
    maxTokens: 800,
    topP: 1,
    systemPrompt: '',
  };

  // Estado en el navegador
  const messages = [];
  let config = { ...DEFAULT_CONFIG };

  // Cargar config guardada
  const saved = localStorage.getItem('assistant-config');
  if (saved) {
    try { config = { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; } catch {}
  }

  // Referencias al DOM
  const $messages = document.querySelector('[data-messages]');
  const $input = document.querySelector('[data-input]');
  const $send = document.querySelector('[data-send]');
  const $modal = document.querySelector('[data-modal]');

  function addMessage(role, content) {
    const div = document.createElement('div');
    div.className = 'msg msg-' + role;
    div.textContent = content;
    $messages.appendChild(div);
    $messages.scrollTop = $messages.scrollHeight;
    return div;
  }

  async function send() {
    const text = $input.value.trim();
    if (!text || $send.disabled) return;

    messages.push({ role: "user", content: text });
    addMessage('user', text);
    $input.value = '';
    $send.disabled = true;

    const thinking = addMessage('thinking', 'Pensando...');

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages, config }),
      });
      const { reply } = await res.json();
      thinking.remove();
      messages.push({ role: "assistant", content: reply });
      addMessage('assistant', reply);
    } catch (e) {
      thinking.remove();
      addMessage('assistant', 'Error: ' + e.message);
    } finally {
      $send.disabled = false;
    }
  }

  $send.addEventListener('click', send);
  $input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') send();
  });

  // ---------- Modal de configuración ----------
  function loadConfigIntoForm() {
    document.querySelectorAll('[data-cfg]').forEach((el) => {
      const key = el.dataset.cfg;
      el.value = config[key];
      const out = document.querySelector('[data-cfg-output="' + key + '"]');
      if (out) out.textContent = config[key];
    });
  }

  // Actualizar output en tiempo real al mover sliders
  document.querySelectorAll('input[data-cfg][type="range"]').forEach((el) => {
    el.addEventListener('input', (e) => {
      const out = document.querySelector('[data-cfg-output="' + e.target.dataset.cfg + '"]');
      if (out) out.textContent = e.target.value;
    });
  });

  document.querySelector('[data-open-config]').addEventListener('click', () => {
    loadConfigIntoForm();
    $modal.hidden = false;
  });

  document.querySelector('[data-close-config]').addEventListener('click', () => {
    $modal.hidden = true;
  });

  document.querySelector('[data-save-config]').addEventListener('click', () => {
    document.querySelectorAll('[data-cfg]').forEach((el) => {
      const key = el.dataset.cfg;
      const raw = el.value;
      if (el.type === 'number') config[key] = parseInt(raw, 10);
      else if (el.type === 'range') config[key] = parseFloat(raw);
      else config[key] = raw;
    });
    localStorage.setItem('assistant-config', JSON.stringify(config));
    $modal.hidden = true;
  });
</script>

¿Por qué Astro + <script> y no React?

Astro compila el <script> como módulo JavaScript normal en el bundle final. Tienes la misma potencia que React para manejar el DOM (querySelector, addEventListener, fetch…) sin aprender hooks ni JSX. Este es exactamente el patrón que se usa en Astro cuando no necesitas un framework de cliente.

Atributos data-* como selectores

En lugar de usar id o clases para buscar elementos desde JS, usamos atributos data-send, data-messages, etc. Deja los class para estilo y los data-* para lógica. Si refactorizas CSS, no rompes el JS.

10 Incluir el chat en una página

En cualquier página .astro:

---
import AssistantChat from '../components/AssistantChat.astro';
---

<main>
  <h1>Asistente interno</h1>
  <AssistantChat />
</main>

Al ser un componente Astro con <script>, no necesita client:load ni ninguna directiva de hidratación. Astro ya sabe que ese script se ejecuta en el navegador.

Verifica que tu Astro config permite SSR

// astro.config.mjs
export default defineConfig({
  output: 'server', // o 'hybrid'
  adapter: node({ mode: "standalone" }),
});

Sin SSR, los endpoints /api/* no existen en tiempo de ejecución.

11 Probar y afinar

Arranca Astro con npm run dev y abre la página. Pregunta:

Tú: ¿Qué productos tenemos en catálogo?
Asistente: Tenemos 4 productos: Teclado mecánico (89,90 €), Ratón inalámbrico (34,50 €), Monitor 27" (299,00 €) y Auriculares USB (59,00 €).
Tú: ¿Cuáles son los pedidos de Juan?
Asistente: Juan Pérez tiene 2 pedidos: uno de 124,40 € y otro de 299,00 €. Total: 423,40 €.

Fíjate en el encadenamiento: para responder "pedidos de Juan", el LLM llamó primero a buscarClientePorNombre, obtuvo el id, y luego a getPedidosPorCliente. Eso es razonamiento con tools.

💡 Afinar el system prompt

Si el asistente da respuestas largas, ajusta el prompt: "Responde en 2 líneas máximo". Si inventa datos, refuérzalo: "Si no puedes obtener el dato con las tools, responde 'No tengo ese dato'". El system prompt es tu palanca más potente.

12 Límites que descubrirás en producción

💸 Coste

Cada mensaje con tool calling usa 2+ llamadas al LLM. gpt-4o-mini cuesta centavos; gpt-4o puede ser 30× más caro. Empieza barato, sube sólo si la calidad lo exige.

⏱️ Latencia

Cada iteración del bucle son 2-4 segundos. Si el LLM encadena 3 tools, el usuario espera 10 s. Usa streaming o mensajes tipo "buscando datos..." para que no parezca congelado.

🎯 Granularidad de las tools

Si tienes 50 tools, el LLM se despista. Agrupa por dominio (catálogo, ventas, clientes) y define tools concretas. Mejor 10 bien descritas que 50 genéricas.

🔒 Validación de argumentos

El LLM puede enviar argumentos raros. Valida en cada handler (Zod, tipos, rangos). No asumas que el LLM ha leído bien el esquema.

📉 Rate limits

OpenAI tiene límite de requests/minuto. Si muchos usuarios charlan a la vez, tendrás errores 429. Añade cola, caché o una cuenta con mejor tier.

13 Variante pro: el asistente usa tu API REST existente

Recomendado en proyectos reales

Hasta aquí has visto las tools consultando la BD directamente (Supabase / SQLite). Pero en tu proyecto de curso probablemente ya tienes un backend Spring Boot + JPA/Hibernate que habla con Supabase, con un CRUD completo expuesto por REST. Aprovéchalo.

La idea es simple: el backend Astro ya no consulta la BD, sino que llama a tu API Spring. El LLM se convierte en "un cliente más" de tu API, igual que el frontend web. Toda la lógica de negocio que tienes en los servicios JPA (validaciones, DTOs, mapeos, permisos) se reutiliza sin duplicar nada.

Arquitectura completa

Navegador
Chat + modal
Astro
/api/chat + tools
LLM
OpenAI
Spring Boot
REST + JPA
Supabase
PostgreSQL

El flecha discontinua (⇢) representa que el LLM no llama a Spring directamente, sino que le dice a Astro qué tool ejecutar.

✅ Por qué este patrón es mejor

  • Reutilizas TODA la lógica de negocio: validaciones, DTOs, servicios JPA, permisos.
  • Consistencia: lo que ve el asistente es lo mismo que ve el frontend.
  • Cero duplicación: si arreglas un bug en Spring, también se arregla para el asistente.
  • Sin CORS: el fetch se hace server-to-server (Astro → Spring).
  • Aislamiento: el LLM no sabe ni que existe JPA, Hibernate ni la estructura real de tablas.

⚠️ Qué considerar

  • Latencia extra: un salto más. Si tu Spring tarda 200 ms, el chat tarda 200 ms más por tool.
  • Tu API debe estar corriendo: si Spring se cae, el asistente deja de funcionar.
  • Autenticación: si tu API pide JWT, el Astro debe añadirlo desde un token de servicio.
  • Solo endpoints GET como tools: nunca exposines POST/PUT/DELETE (ver abajo).

Cómo se escribe tools.js en esta variante

Los esquemas toolSchemas (lo que ve el LLM) son EXACTAMENTE los mismos. Solo cambian los toolHandlers: en vez de queries SQL, hacen fetch a tu API Spring:

const API_BASE = import.meta.env.API_BASE_URL; // ej: http://localhost:8080

// Helper común: añade el token de servicio si existe
async function apiGet(path) {
  const headers = { "Content-Type": "application/json" };
  if (import.meta.env.API_SERVICE_TOKEN) {
    headers['Authorization'] = `Bearer $${import.meta.env.API_SERVICE_TOKEN}`;
  }
  const res = await fetch(`$${API_BASE}$${path}`, { headers });
  if (!res.ok) {
    throw new Error(`API $${res.status}: $${await res.text()}`);
  }
  return res.json();
}

export const toolHandlers = {
  listarProductos() {
    return apiGet('/api/productos');
  },

  buscarClientePorNombre({ nombre }) {
    const q = encodeURIComponent(nombre);
    return apiGet(`/api/clientes?nombre=$${q}`);
  },

  getPedidosPorCliente({ clienteId }) {
    return apiGet(`/api/clientes/$${clienteId}/pedidos`);
  },

  getDetallePedido({ pedidoId }) {
    return apiGet(`/api/pedidos/$${pedidoId}`);
  },

  productosMasVendidos({ limite = 5 } = {}) {
    return apiGet(`/api/productos/mas-vendidos?limite=$${limite}`);
  },

  ventasTotales() {
    return apiGet('/api/ventas/totales');
  }
};

Y el .env añade dos variables:

OPENAI_API_KEY=sk-proj-...
API_BASE_URL=http://localhost:8080
API_SERVICE_TOKEN=eyJhbGci...   # opcional, si tu API requiere auth

Regla de seguridad crítica: solo endpoints GET

Nunca expongas POST, PUT ni DELETE como tool

Aunque tu API tenga DELETE /api/productos/{id}, NO lo pongas como tool. El LLM no debe poder ejecutar operaciones destructivas. La regla es simple: el asistente solo consulta, nunca escribe.

Si en el futuro necesitas que el asistente también "haga cosas" (crear pedidos, actualizar stock), añade esas tools PERO con confirmación humana en el frontend: el LLM propone la acción, el usuario da OK, y tú ejecutas. Esto se llama human-in-the-loop y es el estándar de seguridad para agentes LLM.

Este es el análogo conceptual del RLS con Supabase: tú decides qué verbos de tu API son alcanzables por el asistente. Aunque el LLM pidiese con toda la fuerza borrar algo, no tendría cómo, porque ninguna tool lo permite.

Bonus: el asistente como "tester" de tu API

Una ventaja inesperada: al construir el asistente sobre tu API, te obliga a que tu API esté bien diseñada. Si te cuesta escribir el esquema de una tool porque el endpoint devuelve un JSON raro o no acepta filtros útiles, eso te está diciendo que el endpoint está mal diseñado. El asistente se convierte en auditor de la API.

14 ¿Necesitas más? Ampliación opcional con RAG

Opcional · solo si tu proyecto lo necesita

Lo que acabas de construir es perfecto para datos estructurados: tablas, filas, agregados. Si tu proyecto no maneja documentación libre (manuales, PDFs, políticas), aquí terminas.

Si además quieres que el asistente responda sobre documentación no estructurada, tienes una ampliación opcional donde verás cómo hacerlo con embeddings + pgvector en Supabase, cómo delimitar las respuestas para evitar alucinaciones y cómo citar las fuentes. Pero no lo necesitas para aprobar ni para la mayoría de proyectos.

Ver ampliación opcional: RAG con Supabase