23. Implementación de Routers Conversacionales Inteligentes

Aprende a implementar un router conversacional robusto: gestión de estado, clasificación de intenciones, handlers, flujos multi-paso, Redis, middleware (logging/rate limit) y tests.

Introducción

Los routers conversacionales son el cerebro de un chatbot: deciden cómo manejar cada mensaje del usuario. En este tutorial aprenderás a implementar un router conversacional robusto que gestiona estados, intenciones, y flujos complejos.

Arquitectura de un Router

Un router conversacional efectivo necesita:

  1. Gestión de Estado: Recordar el contexto de la conversación
  2. Resolución de Intenciones: Determinar qué quiere hacer el usuario
  3. Enrutamiento: Dirigir a los handlers apropiados
  4. Comandos Globales: Manejar comandos prioritarios (menú, ayuda)
  5. Fallbacks: Respuestas cuando no se entiende el mensaje

Modelo de Estado de Conversación

public class ConversationState
{
    public required string UserId { get; init; }
    public string? CurrentFlow { get; set; }
    public Dictionary<string, object> Context { get; set; } = new();
    public DateTime LastInteraction { get; set; } = DateTime.UtcNow;
    public string? PendingAction { get; set; }
    public int MessageCount { get; set; }
}

public interface IConversationStateStore
{
    Task<ConversationState> GetAsync(string userId, CancellationToken cancellationToken = default);
    Task SaveAsync(ConversationState state, CancellationToken cancellationToken = default);
    Task ClearAsync(string userId, CancellationToken cancellationToken = default);
}

Implementación del Router

using Microsoft.Extensions.Logging;

public class ConversationRouter
{
    private readonly IConversationStateStore _stateStore;
    private readonly IIntentClassificationService _intentClassifier;
    private readonly ILogger<ConversationRouter> _logger;
    private readonly Dictionary<string, Func<RouteContext, Task<string>>> _handlers;
    
    public ConversationRouter(
        IConversationStateStore stateStore,
        IIntentClassificationService intentClassifier,
        ILogger<ConversationRouter> logger)
    {
        _stateStore = stateStore;
        _intentClassifier = intentClassifier;
        _logger = logger;
        
        // Registrar handlers
        _handlers = new()
        {
            ["menu"] = HandleMenuAsync,
            ["search"] = HandleSearchAsync,
            ["help"] = HandleHelpAsync,
            ["buy"] = HandleBuyAsync,
            ["status"] = HandleStatusAsync
        };
    }
    
    public async Task<string> RouteAsync(
        string userId,
        string message,
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Routing message for user {UserId}: {Message}", userId, message);
        
        // 1. Obtener estado
        var state = await _stateStore.GetAsync(userId, cancellationToken);
        
        // 2. Manejar comandos globales (prioridad máxima)
        var globalResponse = await TryHandleGlobalCommandAsync(message, state);
        if (globalResponse != null)
        {
            state.MessageCount++;
            await _stateStore.SaveAsync(state, cancellationToken);
            return globalResponse;
        }
        
        // 3. Manejar flujo activo
        if (!string.IsNullOrEmpty(state.CurrentFlow))
        {
            var flowResponse = await TryHandleActiveFlowAsync(message, state, cancellationToken);
            if (flowResponse != null)
            {
                state.MessageCount++;
                await _stateStore.SaveAsync(state, cancellationToken);
                return flowResponse;
            }
        }
        
        // 4. Clasificar intención
        var classification = await _intentClassifier.ClassifyAsync(message, cancellationToken);
        
        _logger.LogInformation(
            "Intent classified: {Intent} (confidence: {Confidence})",
            classification.Intent,
            classification.Confidence);
        
        // 5. Enrutar al handler apropiado
        if (_handlers.TryGetValue(classification.Intent, out var handler))
        {
            var context = new RouteContext
            {
                UserId = userId,
                Message = message,
                State = state,
                Classification = classification
            };
            
            var response = await handler(context);
            
            state.MessageCount++;
            await _stateStore.SaveAsync(state, cancellationToken);
            
            return response;
        }
        
        // 6. Fallback
        _logger.LogWarning("No handler found for intent: {Intent}", classification.Intent);
        return "No estoy seguro de entender. ¿Puedes reformular tu pregunta?";
    }
    
    private async Task<string?> TryHandleGlobalCommandAsync(
        string message,
        ConversationState state)
    {
        var normalized = message.ToLowerInvariant().Trim();
        
        // Comando de menú
        if (normalized is "menu" or "menú" or "ayuda" or "help" or "inicio")
        {
            state.CurrentFlow = null;
            state.Context.Clear();
            return BuildMenuMessage();
        }
        
        // Comando de cancelar
        if (normalized is "cancelar" or "cancel" or "salir" or "exit")
        {
            state.CurrentFlow = null;
            state.Context.Clear();
            return "Operación cancelada. ¿En qué puedo ayudarte?";
        }
        
        return null;
    }
    
    private async Task<string?> TryHandleActiveFlowAsync(
        string message,
        ConversationState state,
        CancellationToken cancellationToken)
    {
        // Manejar flujo activo basado en state.CurrentFlow
        return state.CurrentFlow switch
        {
            "purchase" => await HandlePurchaseFlowAsync(message, state, cancellationToken),
            "registration" => await HandleRegistrationFlowAsync(message, state, cancellationToken),
            "feedback" => await HandleFeedbackFlowAsync(message, state, cancellationToken),
            _ => null
        };
    }
    
    private async Task<string> HandleMenuAsync(RouteContext context)
    {
        context.State.CurrentFlow = null;
        context.State.Context.Clear();
        return BuildMenuMessage();
    }
    
    private async Task<string> HandleSearchAsync(RouteContext context)
    {
        var query = context.Classification.Entities?.ContainsKey("query") == true
            ? context.Classification.Entities["query"].ToString()
            : context.Message;
        
        _logger.LogInformation("Handling search for: {Query}", query);
        
        // Implementar búsqueda
        return $"Buscando: {query}...";
    }
    
    private async Task<string> HandleHelpAsync(RouteContext context)
    {
        return """
        Puedo ayudarte con:
        
        🔍 Buscar productos o servicios
        🛒 Realizar compras
        📦 Consultar el estado de pedidos
        📋 Ver el menú de opciones
        
        ¿Qué necesitas?
        """;
    }
    
    private async Task<string> HandleBuyAsync(RouteContext context)
    {
        // Iniciar flujo de compra
        context.State.CurrentFlow = "purchase";
        context.State.Context["step"] = "product_selection";
        
        return "¿Qué producto te gustaría comprar?";
    }
    
    private async Task<string> HandleStatusAsync(RouteContext context)
    {
        var orderId = context.Classification.Entities?.ContainsKey("order_id") == true
            ? context.Classification.Entities["order_id"].ToString()
            : null;
        
        if (orderId == null)
        {
            return "Por favor, proporciona tu número de pedido.";
        }
        
        return $"Consultando estado del pedido {orderId}...";
    }
    
    private async Task<string> HandlePurchaseFlowAsync(
        string message,
        ConversationState state,
        CancellationToken cancellationToken)
    {
        var step = state.Context.TryGetValue("step", out var stepObj)
            ? stepObj?.ToString()
            : "product_selection";
        
        return step switch
        {
            "product_selection" => await HandleProductSelectionAsync(message, state),
            "quantity_selection" => await HandleQuantitySelectionAsync(message, state),
            "confirmation" => await HandlePurchaseConfirmationAsync(message, state),
            _ => "Flujo de compra no válido."
        };
    }
    
    private async Task<string> HandleProductSelectionAsync(
        string message,
        ConversationState state)
    {
        state.Context["selected_product"] = message;
        state.Context["step"] = "quantity_selection";
        
        return $"Has seleccionado: {message}\n\n¿Cuántas unidades deseas?";
    }
    
    private async Task<string> HandleQuantitySelectionAsync(
        string message,
        ConversationState state)
    {
        if (!int.TryParse(message, out var quantity) || quantity <= 0)
        {
            return "Por favor, indica una cantidad válida.";
        }
        
        state.Context["quantity"] = quantity;
        state.Context["step"] = "confirmation";
        
        var product = state.Context["selected_product"];
        
        return $"""
        Resumen de tu pedido:
        • Producto: {product}
        • Cantidad: {quantity}
        
        ¿Confirmas la compra? (sí/no)
        """;
    }
    
    private async Task<string> HandlePurchaseConfirmationAsync(
        string message,
        ConversationState state)
    {
        var normalized = message.ToLowerInvariant().Trim();
        
        if (normalized is "sí" or "si" or "s" or "yes" or "y")
        {
            var product = state.Context["selected_product"];
            var quantity = state.Context["quantity"];
            
            // Procesar pedido
            _logger.LogInformation(
                "Processing purchase: Product={Product}, Quantity={Quantity}",
                product,
                quantity);
            
            // Limpiar flujo
            state.CurrentFlow = null;
            state.Context.Clear();
            
            return $"✅ Pedido confirmado: {quantity}x {product}\n\nRecibirás un correo con los detalles.";
        }
        else
        {
            // Cancelar
            state.CurrentFlow = null;
            state.Context.Clear();
            
            return "Pedido cancelado. ¿Puedo ayudarte con algo más?";
        }
    }
    
    private async Task<string> HandleRegistrationFlowAsync(
        string message,
        ConversationState state,
        CancellationToken cancellationToken)
    {
        // Implementar flujo de registro
        return "Flujo de registro...";
    }
    
    private async Task<string> HandleFeedbackFlowAsync(
        string message,
        ConversationState state,
        CancellationToken cancellationToken)
    {
        // Implementar flujo de feedback
        return "Flujo de feedback...";
    }
    
    private string BuildMenuMessage()
    {
        return """
        🏠 Menú Principal
        
        Opciones disponibles:
        1️⃣ Buscar productos
        2️⃣ Ver mis pedidos
        3️⃣ Hacer una compra
        4️⃣ Ayuda
        
        Escribe el número o describe lo que necesitas.
        """;
    }
}

public class RouteContext
{
    public required string UserId { get; init; }
    public required string Message { get; init; }
    public required ConversationState State { get; init; }
    public required IntentClassificationResult Classification { get; init; }
}

Gestión de Estado con Redis

using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;

public class RedisConversationStateStore : IConversationStateStore
{
    private readonly IDistributedCache _cache;
    private readonly TimeSpan _stateExpiration = TimeSpan.FromHours(24);
    
    public RedisConversationStateStore(IDistributedCache cache)
    {
        _cache = cache;
    }
    
    public async Task<ConversationState> GetAsync(
        string userId,
        CancellationToken cancellationToken = default)
    {
        var key = GetCacheKey(userId);
        var json = await _cache.GetStringAsync(key, cancellationToken);
        
        if (json == null)
        {
            return new ConversationState { UserId = userId };
        }
        
        return JsonSerializer.Deserialize<ConversationState>(json)
            ?? new ConversationState { UserId = userId };
    }
    
    public async Task SaveAsync(
        ConversationState state,
        CancellationToken cancellationToken = default)
    {
        var key = GetCacheKey(state.UserId);
        var json = JsonSerializer.Serialize(state);
        
        await _cache.SetStringAsync(
            key,
            json,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = _stateExpiration
            },
            cancellationToken);
    }
    
    public async Task ClearAsync(
        string userId,
        CancellationToken cancellationToken = default)
    {
        var key = GetCacheKey(userId);
        await _cache.RemoveAsync(key, cancellationToken);
    }
    
    private string GetCacheKey(string userId)
    {
        return $"conversation:state:{userId}";
    }
}

Router con Middleware Pattern

public interface IRouterMiddleware
{
    Task<string?> ProcessAsync(
        RouteContext context,
        Func<RouteContext, Task<string>> next);
}

public class LoggingMiddleware : IRouterMiddleware
{
    private readonly ILogger<LoggingMiddleware> _logger;
    
    public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
    {
        _logger = logger;
    }
    
    public async Task<string?> ProcessAsync(
        RouteContext context,
        Func<RouteContext, Task<string>> next)
    {
        _logger.LogInformation(
            "Processing message from {UserId}: {Message}",
            context.UserId,
            context.Message);
        
        var stopwatch = Stopwatch.StartNew();
        var response = await next(context);
        stopwatch.Stop();
        
        _logger.LogInformation(
            "Response generated in {Elapsed}ms",
            stopwatch.ElapsedMilliseconds);
        
        return response;
    }
}

public class RateLimitMiddleware : IRouterMiddleware
{
    private readonly Dictionary<string, Queue<DateTime>> _userRequests = new();
    private readonly int _maxRequestsPerMinute = 10;
    
    public async Task<string?> ProcessAsync(
        RouteContext context,
        Func<RouteContext, Task<string>> next)
    {
        if (!_userRequests.ContainsKey(context.UserId))
        {
            _userRequests[context.UserId] = new Queue<DateTime>();
        }
        
        var userQueue = _userRequests[context.UserId];
        var now = DateTime.UtcNow;
        var oneMinuteAgo = now.AddMinutes(-1);
        
        // Limpiar requests antiguos
        while (userQueue.Count > 0 && userQueue.Peek() < oneMinuteAgo)
        {
            userQueue.Dequeue();
        }
        
        if (userQueue.Count >= _maxRequestsPerMinute)
        {
            return "Has enviado demasiados mensajes. Por favor, espera un momento.";
        }
        
        userQueue.Enqueue(now);
        
        return await next(context);
    }
}

Testing del Router

using Xunit;
using Moq;

public class ConversationRouterTests
{
    [Fact]
    public async Task RouteAsync_MenuCommand_ReturnsMenuMessage()
    {
        // Arrange
        var mockStateStore = new Mock<IConversationStateStore>();
        mockStateStore
            .Setup(x => x.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ConversationState { UserId = "test-user" });
        
        var mockClassifier = new Mock<IIntentClassificationService>();
        var logger = Mock.Of<ILogger<ConversationRouter>>();
        
        var router = new ConversationRouter(
            mockStateStore.Object,
            mockClassifier.Object,
            logger);
        
        // Act
        var response = await router.RouteAsync("test-user", "menu");
        
        // Assert
        Assert.Contains("Menú Principal", response);
    }
    
    [Fact]
    public async Task RouteAsync_PurchaseFlow_CompletesSuccessfully()
    {
        // Arrange
        var state = new ConversationState { UserId = "test-user" };
        var mockStateStore = new Mock<IConversationStateStore>();
        mockStateStore
            .Setup(x => x.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(state);
        
        var mockClassifier = new Mock<IIntentClassificationService>();
        mockClassifier
            .Setup(x => x.ClassifyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new IntentClassificationResult
            {
                Intent = "buy",
                Confidence = 0.95
            });
        
        var logger = Mock.Of<ILogger<ConversationRouter>>();
        var router = new ConversationRouter(
            mockStateStore.Object,
            mockClassifier.Object,
            logger);
        
        // Act - Start purchase
        var response1 = await router.RouteAsync("test-user", "quiero comprar");
        
        // Act - Select product
        var response2 = await router.RouteAsync("test-user", "libro");
        
        // Act - Select quantity
        var response3 = await router.RouteAsync("test-user", "2");
        
        // Act - Confirm
        var response4 = await router.RouteAsync("test-user", "sí");
        
        // Assert
        Assert.Contains("producto", response1, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("unidades", response2, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("Resumen", response3, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("confirmado", response4, StringComparison.OrdinalIgnoreCase);
    }
}

Mejores Prácticas

1. Prioridad de Comandos

// Global > Flow > Intent > Fallback
private async Task<string> RouteWithPriorityAsync(string message, ConversationState state)
{
    return await TryHandleGlobalCommandAsync(message, state)
        ?? await TryHandleActiveFlowAsync(message, state)
        ?? await TryHandleIntentAsync(message, state)
        ?? GetFallbackMessage();
}

2. Timeouts para Flujos

public bool IsFlowExpired(ConversationState state)
{
    const int FlowTimeoutMinutes = 15;
    return state.LastInteraction.AddMinutes(FlowTimeoutMinutes) < DateTime.UtcNow;
}

3. Context Cleanup

public void CleanupExpiredState(ConversationState state)
{
    if (IsFlowExpired(state))
    {
        state.CurrentFlow = null;
        state.Context.Clear();
    }
}

Conclusión

Un router conversacional bien diseñado es esencial para chatbots robustos. Gestiona estados, enruta correctamente, maneja flujos complejos, y proporciona fallbacks apropiados. El uso de middleware permite agregar funcionalidades como logging y rate limiting de forma modular.


Palabras clave: conversational router, chatbot architecture, state management, intent routing, flow handling, middleware pattern