Sugerencias (snippets) de descripciones
La mejora descrita en el subcapítulo anterior resolvía el problema estructural —que la descripción quedara ligada al formulario de alta— pero no el problema humano: aun teniendo el campo delante, el operador suele dejarlo vacío o rellenarlo con dos palabras genéricas. Describir bien una variable de planta, una regla de alarma o una consulta requiere reconstruir mentalmente el contexto operacional del recurso, y ese esfuerzo es exactamente lo que el usuario está intentando evitar cuando da de alta cincuenta variables seguidas.
Para solucionar esto, smart-snippets-descriptions, sugiere al operador una posible descripción para dicho recurso. Mientras el operador escribe, otro asistente lee la fórmula, la unidad, las variables vigiladas o los parámetros de la consulta, monta un prompt con esa información y va completando el texto en tiempo real. El operador acepta con Tab, rechaza con Esc o sigue tecleando para reorientar la sugerencia.
Una posible tentación inicial puede ser añadir un endpoint POST /api/descriptions/suggest dentro del propio módulo de descripciones, pero eso rompería una de las reglas que vimos al delimitar bounded contexts: el módulo de descripciones describe, no infiere. La solución fue crear un módulo separado bajo dwall-core/dwall-module-smart/smart-snippets/snippets-descriptions/, con la estructura hexagonal habitual —app, domain y persistence—, de manera que el módulo de descripciones siga sin saber que existe Gemini y el de snippets siga sin saber cómo se persiste una descripción; los une un agreement implícito: el frontend pide la sugerencia, recibe texto y, si el operador lo acepta, lo manda al endpoint clásico de creación de la entidad.
Tres recursos, tres contextos, un mismo controlador
El controlador es deliberadamente fino:
@RestController
@RequestMapping("/api/web/smart/snippets/suggest")
public class DescriptionSuggestionController {
@PostMapping(value = "/variable", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamForVariable(@RequestBody final VariableContext context) { ... }
@PostMapping(value = "/rule", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamForRule(@RequestBody final RuleDescriptionRequest request) { ... }
@PostMapping(value = "/query", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamForQuery(@RequestBody final QueryContext context) { ... }
}Fundamentalmente solo se pueden inferir las descripcines de tres recursos diferentes, estos tendra un endpoind cada. Recibirán su respectivo record del dominio (VariableContext, RuleContext, QueryContext) y devuelve un Flux<String> que el navegador consume vía Server-Sent Events. Que el produces sea text/event-stream y no application/json es la decisión arquitectónica más importante del módulo, y se desarrolla más abajo.
La razón de no fusionarlos en un único /suggest polimórfico es que el contexto es la diferencia entre los tres recursos. Una variable tiene fórmula y unidad; una regla tiene fórmula y variables vigiladas; una consulta tiene una ruta con parámetros opacos que hay que decodificar. Forzar un payload común obligaría a campos opcionales por todas partes y a un switch en cualquier sitio donde leyéramos el contexto.
Builders de prompt: el dominio escribe en lenguaje natural
El corazón del módulo no es la integración con Gemini —que se reduce a una llamada de chatModel.stream()— sino la construcción del prompt. Hay un builder por tipo de recurso, cada uno implementando el mismo puerto:
public interface DescriptionPromptBuilder<C extends DescriptionContext> {
String buildPrompt(C context);
}El de variables genera algo parecido a esto:
Eres un experto en plataformas de monitorización industrial.
Un usuario está creando una nueva variable de monitorización con los siguientes campos:
- Nombre: Potencia activa generador 3
- Referencia del cliente: WTG-03-PA
- Fórmula: <ninguna>
- Unidad: kW
- Tag: aerogeneradores
- Descripción parcial ya escrita por el usuario: Potencia activa
Genera una descripción profesional y concisa (máximo 2-3 frases) en español
que explique qué magnitud o métrica representa esta variable y en qué contexto
operacional se utiliza. Si hay una fórmula, interpreta brevemente qué calcula.
Si hay una descripción parcial ya escrita por el usuario, debes comenzar tu
respuesta EXACTAMENTE con ese texto literal —sin reescribirlo, sin reordenarlo,
sin añadir comillas— y continuar desde donde se quedó.
Esos prompts son, en la práctica, el contrato funcional del módulo: si el equipo de producto quiere que las sugerencias sean más cortas, más técnicas o más orientadas a un dominio concreto, el cambio se reduce a una clase de la capa domain y no toca ni el controlador ni el cliente de Gemini. Por eso los builders están en domain y no en persistence: son reglas del negocio sobre cómo se le habla al modelo, no detalles de infraestructura.
El builder de consultas, por su parte, incluye una guía de ruta (ROUTE_GUIDE) que enseña al modelo a interpretar URLs internas tipo /explorer-correlation?pair1=80996,13033&z=41719&zType=intensity y traducirlas a frases comprensibles. Esa guía vive como constante en la propia clase: es conocimiento del dominio de DWall, no de Gemini, y por eso pertenece al builder.
Hidratación: por qué el frontend manda IDs y no objetos
Cuando el operador está creando una regla, el formulario tiene los IDs de las variables que esa regla va a vigilar —no su nombre, ni su unidad, ni su descripción previa. Mandar todo eso desde el frontend supondría duplicar lecturas que ya hace el formulario para sus propios dropdowns y, sobre todo, dar por bueno cualquier contexto que el cliente decida enviar. El servicio resuelve el problema en un único punto:
private List<VariableContext> hydrateVariables(final List<Long> ids) {
final Map<Long, VariableContext> byId = descriptionContextRepository
.findVariableContext(ids)
.stream()
.collect(Collectors.toMap(VariableContext::id, Function.identity()));
return ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.toList();
}La hidratación tira de una vista SQL —module_smart_snippets_descriptions_variable_context— que precalcula el JOIN entre variable, aggregate_variable (para tener la fórmula si es agregada) y module_description_description (para tener la descripción ya escrita, si existe). Es exactamente el mismo patrón de vista precalculada que vimos en el subcapítulo anterior con module_description_named: una sola consulta, un solo viaje, JOINs resueltos en la base de datos en lugar de en Java.
El fantasma: cómo se muestra una sugerencia que aún se está generando
El componente DescriptionField.vue reproduce el patrón de ghost text de editores como GitHub Copilot o IntelliJ: un texto en gris superpuesto al textarea como si fuera la continuación natural de lo que el usuario ha escrito.

La maquinaria DOM es sencilla: encima del textarea hay un div con la misma tipografía y padding que contiene dos <span>, uno invisible que reserva el hueco del texto ya escrito y otro en gris con el sufijo sugerido. Aceptarla es trivial —Tab, Esc o clic sobre el texto gris— y al confirmar se concatena value + ghost marcando una bandera (justAppliedByMe) para que el watcher no interprete el cambio como una nueva pulsación.
La parte que importa es applySuggestion, que decide si el fantasma se renderiza:
applySuggestion (suggestion) {
const cleaned = (suggestion || '').trim();
const current = this.value || '';
if (!cleaned || cleaned === current) {
this.ghost = '';
return;
}
if (cleaned.startsWith(current)) {
this.ghost = cleaned.slice(current.length);
} else {
this.ghost = '';
}
}Si la respuesta del modelo no empieza literalmente con lo que el usuario tiene escrito, el fantasma se descarta. El prompt ya pide al modelo respetar el prefijo, pero la responsabilidad de comprobarlo vive en el cliente: esa es la salvaguarda que evita que una alucinación, o una reescritura espontánea, machaque silenciosamente lo que el operador está tecleando.
Dos debounces, no uno
Hay dos eventos que pueden disparar una sugerencia, y se tratan con tiempos distintos:
| Evento | Debounce | Por qué |
|---|---|---|
| Cambio en el contexto (nombre, fórmula, variables…) | 800 ms | El operador acaba de cambiar de campo; quiere ver una sugerencia razonablemente rápido. |
| Tecleo en la propia descripción | 1500 ms | El operador está escribiendo; interrumpirle con un fantasma cada 200 ms es ruido, no ayuda. |
Cada debounce tiene su propio setTimeout, lo que significa que escribir en la descripción no cancela la sugerencia provocada por un cambio de contexto previo (y viceversa). Sumado a esto, cada petición lleva asociado un requestToken que se incrementa monótonamente, y al unmount del componente se aborta cualquier petición pendiente vía AbortController. La consecuencia práctica es que siempre se renderiza la respuesta de la petición más reciente, sin importar el orden en que vuelvan los streams.
Sobre los debounces hay además un tope duro: si la descripción supera los 280 caracteres —espacio holgado para las dos o tres frases que pide el prompt— el frontend deja de mandar peticiones, y el backend aplica la misma comprobación al recibir el contexto cortando el Flux si el campo entrante ya excede el umbral. Así, ningún cliente mal calibrado ni ninguna pulsación perdida pueden poner al servicio a generar texto indefinidamente.
Encaje con el resto del proyecto
Un snippet no es búsqueda semántica como la del capítulo BDV ni RAG sobre documentos como el del capítulo SPR , pero sí es una funcionalidad smart o agéntica en el mismo sentido que los embeddings o un chat RAG: una llamada a un LLM con un contexto cuidadosamente preparado por el dominio. Lo único que cambia es la dirección del problema: aquí no se consulta una base de conocimiento, se decora un formulario con los campos que ese formulario ya tiene delante.
Combinada con la mejora del campo inline, esta funcionalidad cierra el círculo: el formulario propone, el operador acepta o corrige, y el recurso entra en el sistema con una descripción real desde el primer minuto —la única forma realista de que la base de descripciones, el alimento del agente RAG, crezca al mismo ritmo que los recursos.