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:
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?
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
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.
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:
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
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
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
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