Arquitectura para sistemas de diálogo: diseñar para crecer sin romperlo todo

Los sistemas de diálogos constituyen un problema arquitectónico mucho más interesante de lo que aparentan. Aparecen en contextos aparentemente simples como chatbots de asistencia, así como en entornos notablemente más complejos, como los videojuegos.

Quiero plantear en este artículo algunas reflexiones acerca de la arquitectura apropiada para construir un sistema de diálogos que pueda crecer sin colapsar bajo su propio peso. Cuando abordamos un desarrollo de software, solemos partir de un conjunto de requisitos que rara vez permanecen estables. La diferencia entre un sistema sostenible y una pesadilla técnica suele residir en una cuestión: ¿por dónde es razonable que este sistema crezca en el futuro?

Del árbol al grafo

El modelo mental más inmediato para un diálogo es el de un árbol: partimos de una raíz, y a medida que el usuario toma decisiones, descendemos por distintas ramas hasta desembocar en una hoja, un final. Este enfoque funciona para casos triviales, pero introduce rápidamente limitaciones estructurales. Un árbol impone una direccionalidad rígida donde avanzar es sencillo, pero no lo es retroceder o reutilizar fragmentos.

Para obtener flexibilidad real, el diálogo ha de ser modelado como un grafo dotado de diversos nodos comunicados entre sí.

En concreto, vamos a querer un grafo dirigido y débilmente conexo:

  • Dirigido, porque las transiciones entre nodos tienen una dirección definida. Que uno pueda desplazarse del nodo A al B, no significa que pueda hacerlo del B al A.
  • Débilmente conexo, porque todos los nodos están conectados si no tenemos en cuenta las direcciones y tampoco hay ningún nodo aislado. Todos los nodos pertenecen al mismo diálogo aunque no exista necesariamente un camino de A a B en ambos sentidos.

Este cambio conceptual es lo que nos va a permitir que el sistema crezca de forma orgánica.

Validación estructural

Modelar el dialogo como grafo nos va permitir introducir algo que rara vez se hace correctamente, la posibilidad de una validación estructural. Un diálogo no debería poder desplegarse si contiene nodos inaccesibles, y para detectar esto basta con un enfoque sencillo: 

  1. Convertimos el grafo dirigido (cada diálogo) en un grafo no dirigido, eliminando la representación de la dirección
  2. Recorremos el grafo desde el nodo inicial y verificamos que todos los nodos sean accesibles. Para ello podemos crear una lista de nodos visitados que contenga el nodo inicial y otra de nodos ya comprobados, explorando recursivamente los nodos visitados pero no comprobados y añadiendo a la lista de nodos visitados aquellos que se alcancen desde aquí.
  3. Nos detendremos cuando confirmemos que nuestra lista de nodos visitados contiene todos los nodos de diálogo, fallando si al haberlos visitado todos nos falta alguno, lo que querría decir que el diálogo está incompleto o mal definido.

Representación de los nodos

La peor opción posible, a la vez que sorprendentemente común, consiste en programar una clase codificando los diálogos directamente en el código mediante estructuras monolíticas, switch interminables o condicionales anidados basados en identificadores numéricos. Este enfoque no escala, no se mantiene y convierte cualquier cambio en una operación de alto riesgo. En cuanto el diálogo deja de ser trivial, el sistema se vuelve inmanejable.

Una arquitectura sostenible empieza por separar contenido de comportamiento.

Lo ideal es entonces almacenar los nodos del diálogo como datos: JSON, YAML, XML,… e incluso con ficheros individuales por nodo. Esta última opción resulta especialmente cómoda para edición, depuración y control de versiones, aunque nada impide que posteriormente se transfieran y se carguen desde una base de datos si las características de nuestro sistema lo hacen más conveniente.

Así, un ejemplo sencillo de nodo en JSON sería este:

firstNode.json:

{
  "id": "firstNode",
  "steps": [
    { "type": "line", "speaker": "Revisor", "text": "Billetes, por favor" },
    { "type": "line", "speaker": "Player", "text": "¿Billetes?"},
    { "type": "line", "speaker": "Revisor", "text": "No me haga repetírselo por favor" },
  ],
  "options": [
    {
      "text": "¡Ah sí! Espere un momento, por favor.", "next": "endGood"
    },
    {
      "text": "¡No tengo por qué enseñarle nada!", "next": "endBad"
    }
  ]
}

Cada nodo contiene una secuencia de pasos que se ejecutan en orden y un conjunto de opciones que determinan el siguiente nodo al que se puede dirigir la conversación. En el ejemplo, dependiendo de la opción elegida, saltará al nodo “endGood” o al nodo “endBad”. El dialogo deja con ello de ser código y pasa a ser una estructura navegable.

endGood.json:

{
  "id": "endGood",
  "steps": [
    { "type": "line", "speaker": "Player", "text": "Aquí tiene." },
    { "type": "line", "speaker": "Revisor", "text": "Muchas gracias."},
    { "type": "end" }
  ]
}

En este sistema, cada uno de los pasos (steps) tiene asociado un tipo (type) y una serie de campos adicionales opcionales que van a depender del tipo. En un sistema mínimo bastan dos tipos de paso:

  • line, que muestra una línea de texto
  • end, que finaliza la conversación

Esta decisión es estratégica, pues al definir los pasos como tipos se permite que el sistema crezca sin que sea necesario reescribirlo, y sin que los nuevos tipos afecten a los ya existentes. La complejidad del sistema puede así aumentar con facilidad para adaptarnos a futuros requisitos.

En nuestro ejemplo de un videojuego, pueden emerger fácilmente mayores complejidades. Podemos encontrarnos:

  • Eventos visuales o sonoros que acompañan al diálogo.
  • Comprobaciones del estado del mundo, de modo que los NPCs (Personajes No-Jugadores) reaccionen a lo que sucede a su alrededor.
  • Decisiones automáticas dependientes de variables externas al diálogo presentes en los personajes, como el estado su relación (aliado, neutral, hostil,…).
  • Tiradas aleatorias o checks de habilidad, para decidir si el jugador logra o no algo. Considérese una habilidad de “persuadir” dependiendo de la cual se logre convencer o no de algo al NPC.

Lo importante aquí es que aunque queramos añadir estas nuevas consideraciones, la arquitectura no cambia, solo se amplía el catálogo de pasos (steps) disponibles.

Ejemplo de diálogo y opciones (Disco Elysium)

Arquitectura de clases

En lo que respecta a la arquitectura de clases, una separación limpia podría empezar con la distinción en dos bloques, uno con la estructura de datos con la que representamos los diálogos, otro con las clases de gestión.

La representación de datos la llevarían a cabo estas clases:

  • DialogueGraph: Representa el diálogo completo como grafo. Albergaría una lista de nodos (DialogueNode), así como una referencia al nodo inicial.
  • DialogueNode: Clase que representa un nodo del grafo. Tendría un identificador, una lista de aDialogueStep y una lista de DialogueOption.
  • aDialogueStep: Clase abstracta base para todos los tipos de paso (step). De manera concreta, en nuestro ejemplo tendríamos DialogueStepLine y DialogueStepEnd implementando esta clase abstracta y algún método para obtener el conjunto de datos específico a cada tipo de paso (persona a la que pertenece la frase, nombre del efecto sonoro, etc).
  • FactoryDialogueStep: Dado que los datos a ser guardados en cada paso son diferentes, deberíamos tener una factoría que devuelva un aDialogueStep y que genere uno u otro tipo de step dependiendo del tipo de paso, guardando los datos relevantes en la clase apropiada. Para no tener que utilizar una clase Builder para los datos específicos del tipo de paso, podríamos pasar como parámetro en la creación un DTO con el contenido del paso en el JSON.

Por otro lado, las clases de gestión:

  • DialogueLoader: Dedicada a la carga de los datos del diálogo y su transformación en una clase tipo DialogueGraph. Es decir, su labor es trasladar el JSON a nuestro formato interno.
  • DialogueManager: Esta clase gestiona el estado del diálogo, incluyendo el nodo en el que se encuentra el usuario y el paso actual dentro de este nodo, así como los mecanismos de cambio de estado.
  • DialogueUI: Con esta clase manejaríamos la presentación por pantalla.

Unir gestión de estado y presentación puede resultar tentador, pero es un error clásico que limita evoluciones posteriores. Ambas responsabilidades deben estar separadas.

Diseñar pensando en lo que aún no existe

Con esta base, tendríamos un sistema robusto a la par que flexible para representar conversaciones complejas en grafos débilmente conexos, que nos permitiría hacer frente a una enorme variedad de situaciones sin tener que rehacer la propia arquitectura subyacente. No se trata de implementar todas las posibilidades desde el primer día: lo que queremos es diseñar un sistema capaz de absorberlas cuando aparezcan.

Los sistemas de diálogo, como tantos otros, fracasan cuando se vuelven inmanejables debido a una arquitectura que no admite cambio. Pensarlos como grafos, separar lógica y aislar responsabilidades, lejos de la sobreingeniería académica, supone diseñar de un modo que nos adelantemos a su evolución, asumiendo que todo software que se precie crecerá y cambiará tarde o temprano.

Deja un comentario

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

Scroll al inicio