Symfony AI en la práctica (II): chat conversacional con historial, múltiples tools y Vanilla JS

En el artículo anterior construimos un agente básico que solo sabía hacer una cosa: consultar el precio de un producto a partir de su SKU. Era útil, pero muy limitado.

En esta segunda parte lo convertimos en un asistente conversacional real: ahora puede buscar productos por categoría, mantener el contexto de la conversación y responder de forma natural en español, como si estuviera atendiendo a un cliente de verdad.

Los principales cambios respecto a la primera parte son:

  • Tres herramientas coordinadas: búsqueda por texto/categoría, precio por SKU y listado de categorías disponibles.
  • Historial de conversación persistente entre peticiones, usando la sesión HTTP de Symfony.
  • Catálogo ampliado a 20 productos distribuidos en 4 categorías, con búsqueda basada en slugs normalizados.
  • Interfaz de chat completa hecha 100% con Vanilla JS y Twig, sin frameworks de frontend.

Todo el código actualizado está disponible en el repositorio del proyecto.

Actualización a v0.7.0: Mientras preparába este artículo, Symfony lanzó la versión 0.7.0 del AI Bundle. Actualicé todo el proyecto y nos beneficiamos de varias correcciones importantes:

  • Fix en el Generic Bridge (#1876): corrige los argumentos del factory del platform genérico. Si usabas Groq (u otros proveedores compatibles) a través del bridge genérico en v0.6.0 y tenías errores al inicializarlo, este fix lo soluciona sin tocar tu configuración.
  • Mejoras en el Profiler (#1894, #1748): el panel de AI ya no se rompe cuando la respuesta no es texto plano y desaparecen los duplicados en el listado de tools. Mucho más fiable para depurar durante el desarrollo.
  • Fix en el streaming de Ollama (#1827): cambia el protocolo interno de SSE a NDJSON, lo que soluciona los problemas de streaming con Ollama (la alternativa local gratuita que recomendamos en la primera parte).

Recuerda que Symfony AI sigue en estado experimental y sin promesa de compatibilidad hacia atrás. Siempre consulta la documentación oficial antes de actualizar.

Nota: Este artículo parte del código del artículo anterior. Aquí solo muestro los fragmentos más relevantes de los cambios y adiciones. El código completo está en el repositorio.

Sobre el streaming: En la primera parte prometimos un chat en tiempo real con Server-Sent Events. Finalmente decidimos dedicar este artículo a consolidar bien las bases conversacionales (historial, múltiples tools y una interfaz decente) para no meter demasiadas cosas a la vez. El streaming real será el protagonista del siguiente artículo.

1. De una tool a tres: coordinando herramientas

El problema del agente con una sola herramienta

El agente de la primera parte tenía una limitación muy clara: solo podía responder si el usuario conocía el SKU exacto del producto. Preguntas tan normales como «¿qué portátiles tienes?» o «¿qué auriculares me recomiendas?» lo dejaban completamente mudo sin poder contestar.

Para solucionarlo, añadimos dos herramientas nuevas y mejoramos la existente.

Las tres tools

ProductSearchTool: Busca productos por categoría (usando el slug) y devuelve resultados paginados. Limitamos a 3 productos por página para que las respuestas del modelo no se hagan eternas. El parámetro de página permite al agente pedir más resultados en turnos posteriores si lo necesita.

#[AsTool(
    name: 'search',
    description: 'Searches products by category slug. Returns up to 3 products per page.',
)]
final class ProductSearchTool
{
    /**
     * @param string $category Category slug (e.g. "telefono", "portatil")
     * @param string $page     Page number for pagination (default: "1")
     */
    public function __invoke(string $category, string $page = '1'): string
    {
        $pageInt = max(1, (int) $page);
        $products = $this->productRepository->searchByCategory(
            $category, 3, ($pageInt - 1) * 3
        );

        if (empty($products)) {
            return sprintf('No se encontraron productos en "%s" (pág %d).', $category, $pageInt);
        }

        $result = sprintf('Categoría "%s" — Página %d:' . "\n", $category, $pageInt);
        foreach ($products as $product) {
            $result .= sprintf("- %s (SKU: %s) — %.2f EUR\n",
                $product->getName(), $product->getSku(), $product->getPrice()
            );
        }

        return $result;
    }
}

ProductPriceTool: La misma herramienta de precio del artículo anterior, pero ahora con el nombre corto price para que el prompt la pueda referenciar fácilmente.

ProductCategoryTool: Devuelve la lista de slugs de categorías disponibles. Es clave: el agente debe usarla primero para saber qué categorías puede consultar antes de intentar una búsqueda.

#[AsTool(
    name: 'categories',
    description: 'Returns the list of available product categories in the store.',
)]
final class ProductCategoryTool
{
    public function __invoke(string $_ = ''): string
    {
        $categories = $this->productRepository->getCategories();
        return 'The available product categories are: ' . implode(', ', $categories);
    }
}

Registrar las tools

Gracias al autoconfigure (comportamiento por defecto), las clases con #[AsTool] se registran automáticamente. Si quieres asociarlas explícitamente a un agente concreto, puedes hacerlo en config/packages/ai.yaml:

ai:
    agent:
        default:
            platform: 'ai.platform.generic.groq'
            model: 'llama-3.3-70b-versatile'
            tools:
                services:
                    - { service: 'App\AI\Tool\ProductSearchTool' }
                    - { service: 'App\AI\Tool\ProductPriceTool' }
                    - { service: 'App\AI\Tool\ProductCategoryTool' }

El sistema prompt: más importante de lo que parece

Cuando pasas de una herramienta a tres, el modelo necesita instrucciones mucho más claras para decidir cuál usar en cada momento. Un buen system prompt marca una diferencia enorme.

Message::forSystem(
    "You are a helpful online store assistant. Reply ONLY in Spanish.\n\n" .
    "AVAILABLE TOOLS:\n" .
    "- 'categories': list available types.\n" .
    "- 'search': lists products by category. Pass exact slug (telefono, portatil, auriculares, reloj). Page defaults to '1' ('2' for more).\n" .
    "- 'price': gets the price of an SKU.\n\n" .
    "RULES:\n" .
    "1. Map user synonyms to exact slugs (e.g., 'laptops'->'portatil', 'relojes'->'reloj' or 'celulares'->'telefono').\n" .
    "2. NO HALLUCINATION: DO NOT invoke unlisted tools (like order/checkout). Never invent products or SKUs.\n" .
    "3. NO SALES: You cannot process orders or take payments; decline gracefully."
)

Este prompt corto y directo funciona mucho mejor que los prompts kilométricos llenos de “no hagas esto” que suelen confundir a los modelos. Incluye un mapeo semántico de sinónimos y reglas claras anti-alucinación, lo que reduce drásticamente los errores.

2. Historial de conversación con sesión HTTP

El problema

Antes, cada llamada al endpoint /api/chat era independiente. El agente no recordaba nada de lo que había dicho antes:

Usuario: "¿Qué portátiles tienes?"
Agente:  [busca y devuelve 3 portátiles]

Usuario: "¿Cuál es el más barato de los que me has dicho?"
Agente:  [sin contexto → no sabe a cuáles se refiere]

La solución: sesión + MessageBag

Usamos la sesión nativa de Symfony para guardar el historial como un array simple y lo reconstruimos en cada petición como un MessageBag completo:

final class ProductChatService
{
    public function ask(string $userQuestion): string
    {
        $session = $this->requestStack->getSession();
        $history = $session->get('chat_history', []);
        $history[] = ['role' => 'user', 'content' => $userQuestion];

        $result = $this->defaultAgent->call($this->buildMessageBag($history));
        $answer = (string) $result->getContent();

        // Guard contra fugas de tool calls o JSON corrupto
        if (preg_match('/^[a-z_]+\{/i', trim($answer)) || preg_match('/function=/', trim($answer)) || empty(trim($answer))) {
            $answer = 'Lo siento, no pude obtener la información correctamente. ¿Puedes reformular tu pregunta?';
        }

        $history[] = ['role' => 'assistant', 'content' => $answer];
        $session->set('chat_history', array_slice($history, -10));

        return $answer;
    }

    private function buildMessageBag(array $history): MessageBag
    {
        $messages = [Message::forSystem("You are a helpful online store assistant...")];

        foreach ($history as $msg) {
            $messages[] = match ($msg['role']) {
                'user'      => Message::ofUser($msg['content']),
                'assistant' => Message::ofAssistant($msg['content']),
                default     => null,
            };
        }

        return new MessageBag(...array_filter($messages));
    }
}

Añadimos un pequeño «guard» con regex para evitar que posibles fugas de JSON o llamadas a tools fallidas contaminen la respuesta visible al usuario. En el controlador también tenemos un catch general que devuelve mensajes amigables en caso de error.

Limitamos el historial a los últimos 10 mensajes por una cuestión práctica: más allá de eso el consumo de tokens se dispara.

Con esto, el agente ya puede mantener conversaciones coherentes:

Usuario: "¿Qué portátiles tienes por menos de 1000€?"
Agente:  [devuelve Ultrabook Zen 13 y Portátil Estudiante]

Usuario: "¿Cuál es el más barato?"
Agente:  [con contexto → "La más barata es la Portátil Estudiante a 599€"]

3. Mejoras en el catálogo y la búsqueda

Ampliamos el catálogo a 20 productos reales distribuidos en 4 categorías: smartphones, portátiles, auriculares y smartwatches.

La mejora más importante a nivel técnico es la normalización de categorías. Ahora cada producto tiene un campo category con su slug (portatil, telefono, etc.). Esto permite búsquedas exactas y elimina ambigüedades.

public function searchByCategory(string $category, int $limit = 3, int $offset = 0): array
{
    $search = $this->normalize($category);

    $matches = array_filter($this->products, function (Product $p) use ($search) {
        return $this->normalize($p->getCategory()) === $search;
    });

    return array_slice(array_values($matches), $offset, $limit);
}

private function normalize(string $text): string
{
    return str_replace(
        ['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'],
        ['a', 'e', 'i', 'o', 'u', 'u', 'n'],
        mb_strtolower(trim($text))
    );
}

El agente recibe los slugs válidos a través de ProductCategoryTool y los usa directamente en ProductSearchTool. Mucho más limpio y fiable.

4. Interfaz de chat con Vanilla JS

La plantilla Twig

Todo el frontend está en un único archivo Twig. Sin React, sin Vue, sin npm, sin bundlers:

{# templates/chat/demo.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
<div class="chat-container">
    <div class="chat-header">Asistente de Productos</div>
    <div class="chat-messages" id="chat">
        <div class="message ai">¡Hola! Soy tu asistente de tienda. ¿En qué puedo ayudarte?</div>
    </div>
    <div class="chat-input">
        <input type="text" id="prompt" placeholder="Escribe tu pregunta...">
        <button id="btn-send">Enviar</button>
    </div>
</div>
{% endblock %}

El JavaScript del cliente

El flujo es clásico, pero con algunos detalles importantes: bloqueo de UI para evitar mensajes duplicados, efecto de escritura progresiva y protección básica contra XSS.

async function sendMessage() {
    const text = inputPrompt.value.trim();
    if (!text) return;

    addMessageBubble(text, 'user');
    inputPrompt.value = '';

    btnSend.disabled = true;
    inputPrompt.disabled = true;

    const aiBubble = addMessageBubble('⏳ Pensando...', 'ai');

    try {
        const response = await fetch('/api/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ question: text }),
        });

        const data = await response.json();

        if (!response.ok) {
            aiBubble.innerHTML = `<span class="error">${escapeHTML(data.error)}</span>`;
            return;
        }

        await typeWriter(aiBubble, data.answer);

    } catch {
        aiBubble.innerHTML = '<span class="error">No se pudo conectar con el servidor.</span>';
    } finally {
        btnSend.disabled = false;
        inputPrompt.disabled = false;
    }
}

async function typeWriter(element, text) {
    let current = '';
    for (const word of text.split(' ')) {
        current += word + ' ';
        element.innerHTML = escapeHTML(current).replace(/\n/g, '<br>');
        chatContainer.scrollTop = chatContainer.scrollHeight;
        await new Promise(r => setTimeout(r, 30));
    }
}

function escapeHTML(str) {
    const p = document.createElement('p');
    p.textContent = str;
    return p.innerHTML;
}

Importante: El efecto de escritura es solo visual. El servidor todavía devuelve la respuesta completa de una vez. El “pensando…” se muestra durante toda la generación del modelo. El streaming real (token por token) lo dejaremos para el próximo artículo.

5. Arquitectura resultante

graph TD
    %% Estilos
    classDef fe fill:#1a237e,stroke:#fff,color:#fff,stroke-width:2px;
    classDef logic fill:#0d47a1,stroke:#fff,color:#fff,stroke-width:1px;
    classDef agent fill:#4a148c,stroke:#fff,color:#fff,stroke-width:2px;
    classDef tool fill:#263238,stroke:#fff,color:#fff,stroke-width:1px;
    classDef repo fill:#004d40,stroke:#fff,color:#fff,stroke-width:2px;

    %% Nodos
    FE[Frontend Vanilla JS]:::fe
    PC[ProductChatController]:::logic
    PS[ProductChatService]:::logic
    AI[AgentInterface Groq-Llama]:::agent
    T1[search]:::tool
    T2[price]:::tool
    T3[categories]:::tool
    PR[(ProductRepository)]:::repo

    %% Flujos
    FE --> PC
    PC --> PS
    PS --> AI
    AI --> T1
    AI --> T2
    AI --> T3
    T1 --> PR
    T2 --> PR
    T3 --> PR
    

6. Resumen de cambios respecto al artículo anterior

AspectoArtículo 1Artículo 2
Tools1 (price)3 (search, price, categories)
HistorialNoSesión HTTP, últimos 10 mensajes
BúsquedaSolo por SKU exactoPor categoría normalizada (slugs) + paginación
FrontendNingunoChat UI con Vanilla JS y efecto de escritura
Catálogo4 productos20 productos en 4 categorías
Sistema prompt2 líneasInstrucciones detalladas + reglas anti-alucinación
RobustezBásicaGuard contra fugas de JSON + manejo de errores

7. Novedades de la v0.7.0 que afectan a este proyecto

Además de los fixes ya mencionados en la introducción, la versión 0.7.0 trae otras mejoras interesantes:

  • Validación de argumentos de tools con Symfony Validator (#1681). Ahora puedes poner constraints directamente en los parámetros del __invoke() y el framework se encarga de validarlos antes de ejecutar la tool.
  • Streaming en el componente Chat (#1583 y #1759): soporte mejorado y objetos DeltaInterface tipados. Esto nos va a facilitar mucho la vida en el próximo artículo.

Hasta donde llegamos y próximos pasos

Con lo que hemos visto en este artículo, ya tenemos un asistente conversacional bastante completo: maneja múltiples tools de forma coordinada, recuerda el contexto de la conversación y ofrece una interfaz limpia hecha con herramientas nativas de Symfony y JavaScript puro.

Es un ejemplo realista de cómo empezar a integrar IA en aplicaciones PHP/Symfony sin complicaciones innecesarias y manteniendo el control total del código.

Todavía queda pendiente la gran mejora de experiencia de usuario: el streaming real. En el próximo artículo nos centraremos exclusivamente en implementar Server-Sent Events para que la respuesta aparezca token por token, eliminando esa molesta espera inicial.

Gracias a la actualización a v0.7.0 y las nuevas capacidades del componente Chat, esa implementación debería quedar mucho más limpia y robusta.

El repositorio está público y puedes levantarlo en minutos. Si tienes dudas, mejoras o quieres ver alguna funcionalidad extra, déjame un comentario. En el siguiente artículo nos metemos de lleno con el streaming.

Sigamos codificando.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.