07_AOR/01 -- Cimientos del agente, arquitectura del bloque smart.md

Cimientos del agente: arquitectura del bloque smart

Estado del capítulo

El módulo del Agente Orquestador (AOR) está en construcción y, a la fecha de entrega de la asignatura de GAISE (15 de mayo de 2026), no estará terminado. Lo que sí se documenta aquí es la parte que ha quedado completada durante el TFG: el refactor estructural que prepara el terreno —reagrupación de todos los bounded contexts relacionados con IA bajo dwall-module-smart y una división interna más fina por agregados-como-slices— y la implementación de los cimientos del propio agente, con la persistencia de conversaciones, el intercambio de mensajes usuario/asistente y el ciclo de feedback ya operativos. La pieza que falta es la conducta del agente —ruteo a agentes especializados, ejecución multi-paso, herramientas— que se aborda como continuación del proyecto y queda fuera del alcance temporal del TFG.

A diferencia de los capítulos anteriores, este no documenta una funcionalidad terminada en producción. Documenta infraestructura conceptual: la forma del bloque sobre el que se va a construir el agente. Esa forma no es un detalle de organización de carpetas, sino una decisión arquitectónica —y, como el resto del proyecto, defendible por separado de la conducta que aún no existe.


El refactor: un único dwall-module-smart para todo lo que es IA

Hasta este punto del proyecto, los módulos relacionados con inteligencia artificial vivían dispersos en dwall-core/ con prefijos descriptivos pero independientes: dwall-module-embeddings para las descripciones del capítulo BDV, dwall-module-embeddings-files para los archivos del capítulo SPR, y los snippets del capítulo EPL aún sin ubicación firme. Cada módulo era hexagonal y autocontenido, pero a nivel de árbol de proyecto la familia "IA" no estaba reconocida como tal: convivía con dwall-module-description, dwall-module-files o dwall-module-rule como un módulo cualquiera.

Para introducir el agente sin que la raíz de dwall-core se llenara de carpetas con prefijo dwall-module-*-ai, se introdujo un módulo paraguas:

dwall-core/
└── dwall-module-smart/
    ├── smart-chat/                    ← bounded context del agente conversacional
    ├── smart-embeddings/              ← embeddings de descripciones (movido)
    ├── smart-embeddings-feedback/     ← embeddings sobre feedback (nuevo, ver más abajo)
    ├── smart-embeddings-files/        ← embeddings de archivos (movido)
    └── smart-snippets/                ← sugerencias snippets de descripciones (movido)

Lo importante del refactor es lo que no cambia. Los paquetes Java se renombraron (net.xabet.digitalwall.embeddings.*net.xabet.digitalwall.smart.embeddings.*), pero las tablas de base de datos conservan sus nombres, las migraciones Flyway se mantienen, y los @EventListener siguen recibiendo los mismos eventos del system bus. Es decir: el comportamiento de los módulos previos no se ve alterado. Lo que cambia es la legibilidad estructural. Cualquiera que abra dwall-core/ puede ver de un vistazo qué partes del producto son IA, sin tener que recordar qué prefijos significan qué cosa, y los nuevos bounded contexts del agente entran en un sitio que ya tiene contexto en lugar de mezclarse con los módulos de negocio.

Este es también el primer punto del proyecto en el que se hace explícita una jerarquía de dos niveles dentro del código: ya no hay solo módulos Maven con capas hexagonales, sino familias de módulos agrupadas por tema. La razón es práctica: cuando empieza a haber cuatro o cinco bounded contexts que comparten un dominio funcional —en este caso, la IA generativa— la convivencia plana se vuelve ruido. El paraguas elimina ese ruido sin imponer ninguna obligación técnica: cada hijo sigue siendo un módulo independiente con su propia capa hexagonal.


DDD un paso más allá: el slice como agregado

Los módulos anteriores ya seguían una estructura hexagonal por capas: -domain, -app, -persistence. Esa división es suficiente cuando el bounded context contiene un agregado principal y, a lo sumo, alguna entidad satélite. En el caso del agente conversacional eso ya no se cumple: dentro del bounded context del chat conviven varios agregados con ciclos de vida claramente distintos —una conversación, los mensajes que la componen, el feedback que el usuario da sobre cada respuesta, y la ejecución del propio agente—. Apilarlos en un único chat-domain habría reproducido a pequeña escala el mismo problema que el módulo paraguas resuelve a gran escala: ruido conceptual mezclando piezas que no comparten ni invariantes ni vida útil.

La solución fue introducir, dentro de smart-chat/, una subdivisión por agregado, donde cada agregado es a su vez un módulo Maven con sus tres capas:

smart-chat/
├── chat-conversation/                    ← agregado ConversationSession
│   ├── chat-conversation-domain/
│   ├── chat-conversation-app/
│   └── chat-conversation-persistence/
├── chat-message/                         ← agregados Message + AssistantMessage
│   ├── chat-message-domain/
│   ├── chat-message-app/
│   └── chat-message-persistence/
├── chat-feedback/                        ← agregado MessageFeedback
│   ├── chat-feedback-domain/
│   ├── chat-feedback-app/
│   └── chat-feedback-persistence/
└── chat-orchestrator/                    ← coordinador + cimiento de AgentExecution
    ├── chat-orchestrator-domain/
    └── chat-orchestrator-app/

Cada slice es, en lo esencial, un agregado vestido con su capa hexagonal mínima. La regla de oro de DDD —el agregado es la frontera de consistencia transaccional— se proyecta así sobre la estructura de carpetas: si dos cosas viven juntas dentro de un slice, comparten transacción; si viven en slices distintos, su coherencia se mantiene por eventos o por commands explícitos enviados a través del CommandBus. Lo que antes era una regla mental se convierte en una regla del build.

Esto tiene una consecuencia que conviene anticipar: el coste de "abrir" un nuevo agregado dentro del bounded context del chat ya no es una clase y una tabla, sino tres módulos Maven con sus respectivas dependencias. Es un coste deliberadamente alto, porque obliga a justificar la decisión: si tengo que crear chat-foo-domain / chat-foo-app / chat-foo-persistence, lo razonable es que "foo" sea de verdad un agregado, no una entidad parásita.

Los cuatro slices y qué encierra cada uno

  • chat-conversation modela la sesión: a quién pertenece, cuándo empezó y, opcionalmente, un título —que puede no existir aún si la conversación es demasiado corta para merecerlo, en cuyo caso el agregado se construye vía ConversationSession.createUntitled() y el título se infiere más tarde a partir del primer intercambio. El agregado conoce su identidad (ConversationSessionId) y poco más: ni mensajes, ni feedback, ni ejecuciones. Esa pobreza intencional es lo que permite manipularlo —renombrarlo, archivarlo, eliminarlo— sin arrastrar tablas masivas.
  • chat-message contiene los mensajes intercambiados, y es donde aparece la decisión de modelado más relevante del bloque (se trata en la siguiente sección).
  • chat-feedback modela el feedback en texto libre que el usuario emite sobre una respuesta del asistente — un único feedback por usuario y por mensaje, garantizado tanto por la lógica del agregado como por un índice único a nivel de tabla. Tiene su propio agregado, su propia tabla y sus propios eventos, porque su ciclo de vida es ortogonal al del mensaje: el mensaje se crea cuando el asistente responde, el feedback puede llegar minutos u horas después y modificarse varias veces.
  • chat-orchestrator es la pieza que coordina todo lo anterior: recibe el mensaje del usuario, recupera la conversación previa, construye el prompt, invoca el cliente LLM —hoy un StubLlmClient para mantener el ciclo cerrado mientras se integra el modelo real— y publica el AssistantMessageSent que el resto de slices observa. Aquí es donde, en el futuro, vivirá el agregado AgentExecution: la representación de una ejecución multi-paso del agente con sus llamadas a herramientas, intentos fallidos y trazas. Esa pieza todavía no existe; lo que sí existe es la forma del lugar donde encajará.

La eliminación de una conversación ilustra cómo coordinan estos slices sin acoplarse. Cuando un usuario borra una sesión, el ConversationSessionService no toca directamente ni mensajes ni feedback: orquesta dos commands a través del CommandBusDeleteMessagesByConversation y DeleteFeedbacksByConversation— y los respectivos slices los manejan en sus propias transacciones. Cada slice responde por sus datos; el coordinador no necesita conocer su esquema.


El patrón Message ↔ AssistantMessage: herencia que la base de datos también ve

Dentro del slice de mensajes hay dos agregados que comparten una identidad fundamental —ambos son un mensaje en una conversación— pero divergen en lo que añade el asistente sobre el usuario. Modelarlos como una sola clase con campos opcionales sería empobrecer la semántica: ¿qué significa un mensaje del usuario con tokenUsage? ¿O un mensaje del asistente que no respondsTo ninguno? La respuesta correcta es que esos campos no son opcionales —son constitutivos— y por tanto pertenecen a un tipo distinto.

La solución reutiliza un patrón que ya aparecía en el módulo de descripciones: las variables normales y las variables agregadas. En aquel caso, una variable agregada era una variable, pero con una fórmula extra y una referencia a las variables que la componían; en términos de base de datos, esto se materializaba como una fila adicional en una tabla satélite que se unía a la principal por el id de la variable original. Aquí ocurre exactamente lo mismo:

public final class AssistantMessage extends Message {

    private final MessageId respondsTo;
    private final TokenUsage tokenUsage;

    // ...constructores, factories y getters
}

AssistantMessage hereda de Message toda la identidad común —MessageId, ConversationSessionId, MessageContent, MessageOrder, createdAt— y añade los dos campos que solo tienen sentido para una respuesta del asistente: el MessageId del mensaje del usuario al que responde y el TokenUsage (prompt + completion tokens) que costó generar la respuesta. El que TokenUsage sea un value object del dominio, y no un par de int sueltos, no es un capricho de purista: significa que cualquier evolución futura —por ejemplo, separar tokens de pensamiento extendido, o registrar el coste estimado en moneda— se hace en un único sitio.

En la base de datos esta jerarquía se proyecta del modo más directo posible: existe una tabla module_smart_chat_message con los campos comunes, y una tabla module_smart_chat_assistant_message que extiende a la anterior aportando responds_to y los campos de token_usage, unida a la primera por el id del mensaje original. Es la herencia traducida a esquema relacional sin trucos: para leer un mensaje del asistente se hace un JOIN; para leer un mensaje cualquiera —usuario o asistente— basta con la tabla principal. La consistencia entre las dos se garantiza dentro del mismo slice y la misma transacción, que es lo que justifica que ambas vivan juntas en chat-message-persistence en lugar de en slices separados.

El paralelismo con el módulo de descripciones

Esta es la segunda ocasión en el proyecto en que aparece la misma técnica: una entidad base (variable / mensaje) con una especialización opcional (variable agregada / mensaje del asistente) materializada como tabla satélite unida por el id de la fila original. Reutilizar el patrón en dos bounded contexts sin reescribirlo no es casualidad —es la confirmación de que el patrón en sí es parte del lenguaje del proyecto, no un truco puntual del módulo de descripciones.


La estructura relacional actual

Los cuatro slices descritos arriba se proyectan sobre cuatro tablas independientes —una por slice— cada una gestionada por su propia schema version table de Flyway, porque cada slice es propietario absoluto de sus migraciones. Las flechas entre tablas no son FOREIGN KEY declaradas en SQL sino claves conceptuales: el conversation_id que un mensaje arrastra, el responds_to que un mensaje del asistente apunta hacia el del usuario, o el assistant_message_id que un feedback contiene son simples UUID cuya integridad la garantiza el dominio, no la base de datos. Esa decisión es deliberada: cada slice solo conoce sus propias tablas, y declarar FKs cruzadas violaría la frontera del agregado.

Tres detalles del esquema ya operativo merecen mención explícita, porque encarnan decisiones de modelado que no se ven a simple vista:

  • module_smart_chat_assistant_message.message_id es a la vez clave primaria y referencia conceptual al id de module_smart_chat_message: es el patrón "tabla satélite" descrito en la sección anterior llevado a su forma mínima — un mensaje del asistente no tiene id propio, su identidad es la del mensaje base que extiende. Permite, además, leer un mensaje cualquiera (usuario o asistente) consultando solo module_smart_chat_message, y los campos específicos del asistente con un LEFT JOIN sobre la satélite.
  • responds_to apunta a module_smart_chat_message, no a otro assistant_message: cada respuesta del asistente referencia el mensaje del usuario que la motivó. Esto vale tanto para una conversación lineal como para una eventual ramificación futura del agente (varias respuestas alternativas a un mismo prompt) sin tocar el esquema.
  • UNIQUE (assistant_message_id, owner_id) sobre module_smart_chat_feedback: la invariante de "un feedback por usuario y por mensaje" se garantiza dos veces — una en el agregado, otra como red de seguridad en el índice único. Si el usuario revisa su opinión, la fila se actualiza (de ahí updated_at); el modelo no acumula valoraciones múltiples del mismo usuario.

Las cuatro tablas proyectadas: AgentExecution y sus tipos de entidad

Las cinco últimas tablas del diagrama —module_smart_chat_agent_execution y sus tres satélites— no están migradas todavía: representan el modelo que se desplegará cuando el agregado AgentExecution entre en escena, y forman parte de este capítulo precisamente porque "tener pensado el sitio" es lo que el TFG aporta sobre el bloque smart. Cada ejecución del agente —una pregunta del usuario, una respuesta final del asistente— quedará registrada como una fila en module_smart_chat_agent_execution, anclada al assistant_message_id que originó. Esa cardinalidad 1-a-1 entre assistant_message y agent_execution materializa el principio de que una respuesta del asistente es la observable de una ejecución; el desglose de qué ocurrió dentro vive en las tres satélites, una por tipo de entidad.

Esos tres tipos —que el dominio modelará como entidades hijas del agregado AgentExecution— corresponden a los tres niveles de granularidad en los que el agente coordinará trabajo:

  • agent_execution_agent registra cada sub-agente especializado al que el orquestador delega. Una pregunta sobre descripciones se encamina al agente BDV, una sobre archivos al agente SPR, una consulta tabular al agente determinista. El agent_name y el order_index permiten reconstruir el camino de delegación en el orden en que ocurrió, y los campos input/output capturan el prompt concreto y la respuesta de cada paso — una capacidad de auditoría sin la cual depurar un agente multi-paso es ciencia ficción.
  • agent_execution_flow registra los flujos activados durante la ejecución, entendidos como secuencias coordinadas de pasos que viven por encima del agente individual (por ejemplo, "buscar contexto en BDV → enriquecer con datos de SPR → componer la respuesta"). El JSONB context deja espacio para el estado intermedio sin imponer un esquema rígido — los flujos, al fin y al cabo, no tienen aún forma definitiva.
  • agent_execution_skill registra las herramientas (skills, tools) invocadas durante la ejecución: cada skill_name con sus arguments y result en JSONB. Esta tabla es la que permitirá, más adelante, alimentar el módulo de herramientas MCP del capítulo siguiente: cada skill externa que el agente llame queda trazada aquí con sus parámetros y su salida.

La razón de tener tres tablas en lugar de una sola con step_type es estrictamente DDD: agentes, flujos y skills son entidades distintas con invariantes distintos (un flow puede no completarse y arrastrar un context parcial; una skill requiere argumentos válidos antes de invocarse; un agente delega texto plano). Forzar un único schema con campos opcionales por todas partes —el clásico "tabla polimórfica con un payload JSONB sin estructura"— colapsaría los tres conceptos y obligaría a un switch cada vez que un consumidor leyera un paso. Tres tablas paralelas mantienen separadas las invariantes y permiten que el agregado AgentExecution exponga tres colecciones tipadas en lugar de una lista heterogénea.


Lo que ya es funcional: smart-embeddings-feedback

El agente como tal no responde aún —el StubLlmClient devuelve texto predefinido—, pero uno de los bloques que si esta acabado es: smart-embeddings-feedback. Este bounded context paralelo escucha los eventos emitidos por chat-feedback cuando un usuario valora una respuesta del asistente, y construye sobre ellos una base de búsqueda semántica de feedback.

La idea es directamente análoga a la del capítulo BDV, pero aplicada a una base de conocimiento muy concreta: no se vectorizan descripciones de recursos, sino los textos asociados a un feedback —el mensaje al que se refiere, la valoración del usuario y, si lo hay, el comentario libre que ha escrito—. El resultado es una memoria operativa: cada respuesta del asistente que ha provocado una reacción del usuario queda indexada y es recuperable por similitud, lo que abre dos casos de uso inmediatos —uno ya en marcha, otro a un paso— que justifican que la pieza exista incluso antes de que el agente esté completo:

  1. Mejora continua del prompt. Cuando el agente final esté integrado, los feedbacks negativos cercanos en significado a la nueva consulta del usuario podrán inyectarse en el contexto como ejemplos de "lo que el usuario no quiere", reduciendo la probabilidad de repetir errores que ya se han marcado.
  2. Análisis de regresión semántica. Es posible buscar, sin ejecutar el agente, cuántas respuestas previas habrían provocado las mismas críticas que un nuevo prompt en pruebas — un test cualitativo barato que no requiere humanos en el bucle.