This entry is part 23 of 25 in the series Introducción a Microsoft Semantic Kernel

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

Share this content:

Introducción a Microsoft Semantic Kernel

2. Seguridad en Aplicaciones de IA 4. Filtrado por Relevancia Semántica en Búsquedas

por David Cantón Nadales

David Cantón Nadales, ingeniero de software de Sevilla, España, es autor del bestseller Build Your own Metaverse with Unity. Reconocido como Microsoft MVP y Top Voices en Aplicaciones Móviles de LinkedIn. Con más de 20 años de experiencia, David ha liderado cientos proyectos a lo largo de su carrera, incluyendo videojuegos y aplicaciones de realidad virtual y aumentada con Oculus, Hololens, HTC Vive, DayDream y LeapMotion. Ha trabajado como Tech Lead en importantes multinacionales como Grupo Viajes El Corte Inglés y actualmente en SCRM Lidl del Grupo Schwarz. Fue embajador de la comunidad Samsung Dev Spain y organizador del Google Developers Group Sevilla. Durante el confinamiento por COVID-19, destacó como emprendedor social con la creación de Grita, una red social que facilitaba el apoyo psicológico entre personas. En 2022, ganó los Samsung Top Developers Awards.