feat(umind): logo propio, plantillas de agente por rubro y página promocional

Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y
esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con
el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro,
que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal,
el header, el sidebar del Studio y la promo, y va también de favicon.

Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el
conocimiento desde cero frente a un campo en blanco es donde la mayoría
abandona. Al crear uno se puede elegir un punto de partida —tienda,
restaurante, consultorio, servicios, peluquería o la base genérica— y arranca
con sus notas ya escritas, listas para editar.

Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay
que reemplazarlos: una nota que parezca dato real termina en boca del asistente.
Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite —
el asistente no da consejo médico ni interpreta síntomas — porque es el único
rubro donde una respuesta inventada hace daño de verdad.

El catálogo vive en código, no en base: es contenido editorial que mejoramos
nosotros, no configuración que cada instalación toca por su lado. Las notas se
cargan en segundo plano porque cada una necesita sus embeddings, y hacer
esperar el alta sería castigar justo al que eligió plantilla.

Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de
guardar quedaba abajo del borde: el modal ahora tiene su propio scroll.

Y la página promocional de uMind queda servida en /umind.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-19 18:21:46 -05:00
co-authored by Claude Opus 5
parent 3ea17b0980
commit af71b66bb8
17 changed files with 1797 additions and 38 deletions
+300
View File
@@ -0,0 +1,300 @@
package services
import (
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// PlantillaRubro es un punto de partida para un agente nuevo: el conocimiento
// base que casi todos los negocios de ese rubro necesitan, ya escrito.
//
// No reemplaza la información real del cliente — la reemplaza el cliente
// editando estas notas. Existe porque un agente vacío no sirve para nada el
// primer día, y escribir desde cero frente a un campo en blanco es donde la
// mayoría abandona.
type PlantillaRubro struct {
Clave string `json:"clave"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Tono string `json:"tono"`
Bienvenida string `json:"mensaje_bienvenida"`
Notas []NotaPlanura `json:"notas"`
}
// NotaPlanura es una nota de conocimiento con su texto de ejemplo. El texto
// está escrito para que se note que hay que cambiarlo: valores entre
// corchetes, nunca datos inventados que parezcan reales.
type NotaPlanura struct {
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
}
// PlantillasRubro es el catálogo. Vive en código y no en base de datos a
// propósito: son contenido editorial que cambia cuando lo mejoramos nosotros,
// no configuración que cada instalación toca por su lado.
var PlantillasRubro = []PlantillaRubro{
{
Clave: "generico",
Nombre: "Negocio general",
Descripcion: "La base que sirve para cualquier rubro: horarios, contacto, formas de pago.",
Tono: "amable y directo",
Bienvenida: "¡Hola! Contame en qué te puedo ayudar.",
Notas: []NotaPlanura{
{
Titulo: "Horarios y contacto",
Contenido: `Atendemos de [lunes a viernes] de [9:00] a [18:00], y los [sábados] de [9:00] a [13:00].
Los [domingos] y feriados no atendemos.
Nos pueden escribir por WhatsApp al [número] o al correo [correo].
Estamos en [dirección completa], [ciudad].`,
},
{
Titulo: "Formas de pago",
Contenido: `Aceptamos [efectivo, tarjeta débito y crédito, transferencia y Nequi].
[No] aceptamos [cheques].
Para transferencias, la cuenta es [banco, tipo de cuenta, número] a nombre de [titular].
El pago se confirma cuando [nos llega el comprobante / se acredita].`,
},
{
Titulo: "Qué NO hacemos",
Contenido: `Escribí acá lo que la gente pregunta seguido y la respuesta es que no.
Ejemplos: no hacemos [envíos fuera de la ciudad], no trabajamos con [cierta marca],
no atendemos [sin cita previa].
Esto evita que el asistente prometa cosas que después no podés cumplir.`,
},
},
},
{
Clave: "tienda",
Nombre: "Tienda / e-commerce",
Descripcion: "Envíos, cambios, garantía y estado de pedidos.",
Tono: "cercano y resolutivo",
Bienvenida: "¡Hola! ¿Buscás algo en particular o querés saber por un pedido?",
Notas: []NotaPlanura{
{
Titulo: "Envíos y zonas de cobertura",
Contenido: `Hacemos envíos a [ciudades o zonas].
El costo del envío es [$X] y demora [N] días hábiles.
Envío gratis por compras superiores a [$X].
A [zonas donde no llegan] no hacemos envíos.
El pedido se despacha [el mismo día si se hace antes de las X / al día siguiente].`,
},
{
Titulo: "Cambios y devoluciones",
Contenido: `Se pueden cambiar productos dentro de los [N] días desde la entrega.
El producto tiene que estar [sin uso, con etiqueta, en su empaque original].
[No] aceptamos cambios en [productos en promoción / ropa interior / etc.].
El costo del envío del cambio lo cubre [el cliente / nosotros].`,
},
{
Titulo: "Garantía",
Contenido: `Los productos tienen [N meses] de garantía por defectos de fábrica.
La garantía no cubre [mal uso, daño por golpes, desgaste normal].
Para hacerla efectiva hace falta [la factura / el número de pedido].`,
},
{
Titulo: "Estado de un pedido",
Contenido: `Si preguntan por un pedido, pediles el [número de pedido] y decíles que
alguien del equipo lo revisa y responde en [tiempo].
No inventes estados de pedido: el asistente no tiene acceso al sistema de envíos.`,
},
},
},
{
Clave: "restaurante",
Nombre: "Restaurante / comida",
Descripcion: "Menú, reservas, domicilios y restricciones alimentarias.",
Tono: "cálido y breve",
Bienvenida: "¡Hola! ¿Querés ver el menú, reservar o pedir a domicilio?",
Notas: []NotaPlanura{
{
Titulo: "Horarios de cocina y domicilios",
Contenido: `La cocina atiende de [12:00] a [15:00] y de [19:00] a [23:00].
Los domicilios salen hasta las [22:30].
[Día] cerramos.
El domicilio demora entre [N] y [M] minutos según la zona.`,
},
{
Titulo: "Zonas de domicilio y costo",
Contenido: `Llevamos a [barrios o zonas].
El costo del domicilio es [$X] o gratis por pedidos mayores a [$X].
A [zonas] no llegamos.
Pedido mínimo: [$X].`,
},
{
Titulo: "Reservas",
Contenido: `Tomamos reservas para grupos de [N] personas o más.
Se reserva con [N horas] de anticipación.
[Sí/No] cobramos seña.
Las mesas se guardan [N] minutos después de la hora reservada.`,
},
{
Titulo: "Restricciones y alergias",
Contenido: `Tenemos opciones [vegetarianas / veganas / sin gluten / sin lactosa]: [cuáles].
Nuestra cocina [sí/no] es libre de [maní, mariscos, gluten] — importante para alergias.
Ante una consulta por alergia grave, siempre derivar a una persona del equipo.`,
},
},
},
{
Clave: "salud",
Nombre: "Consultorio / clínica",
Descripcion: "Turnos, obras sociales y qué NO puede responder un asistente.",
Tono: "profesional y claro",
Bienvenida: "Hola, ¿querés sacar un turno o consultar por coberturas?",
Notas: []NotaPlanura{
{
Titulo: "Límite importante: no damos consejo médico",
Contenido: `Este asistente NO responde consultas médicas, no interpreta síntomas,
no sugiere tratamientos ni medicamentos, y no dice si algo es urgente.
Ante cualquier consulta de salud, la respuesta es derivar a un profesional
y ofrecer sacar un turno. Si la persona describe una urgencia, indicar que
llame al [número de emergencias] o vaya a la guardia más cercana.`,
},
{
Titulo: "Turnos",
Contenido: `Atendemos con turno previo de [lunes a viernes] de [horario].
Los turnos se sacan por [WhatsApp / teléfono / la web].
La consulta dura aproximadamente [N] minutos.
Si no podés venir, avisanos con [N horas] de anticipación.`,
},
{
Titulo: "Obras sociales y particulares",
Contenido: `Trabajamos con [lista de obras sociales / prepagas].
[No] atendemos por [obras sociales específicas].
La consulta particular cuesta [$X].
Traer [documento, credencial, orden] el día del turno.`,
},
{
Titulo: "Dirección y cómo llegar",
Contenido: `Estamos en [dirección], [ciudad].
[Referencias para llegar].
[Hay / no hay] estacionamiento.
El consultorio [es / no es] accesible para silla de ruedas.`,
},
},
},
{
Clave: "servicios",
Nombre: "Servicios profesionales",
Descripcion: "Para estudios, agencias, técnicos: qué hacen, cómo cotizan, plazos.",
Tono: "profesional y concreto",
Bienvenida: "Hola, contame qué necesitás y te oriento.",
Notas: []NotaPlanura{
{
Titulo: "Qué hacemos y qué no",
Contenido: `Nos dedicamos a [descripción del servicio].
Trabajamos con [tipo de clientes / rubros].
No hacemos [servicios fuera del alcance] — para eso conviene [alternativa].`,
},
{
Titulo: "Cómo cotizamos",
Contenido: `Cada trabajo se cotiza según [alcance, horas, complejidad].
Un [servicio típico] arranca en [$X].
La cotización es [gratuita] y demora [N] días.
Para cotizar necesitamos saber [qué datos].
Nunca des un precio cerrado si no está en esta lista: ofrecé armar una cotización.`,
},
{
Titulo: "Plazos y forma de trabajo",
Contenido: `Un proyecto [típico] lleva entre [N] y [M] [semanas].
Trabajamos con [N]% de anticipo y el resto [contra entrega / en cuotas].
Las entregas se coordinan por [canal].`,
},
},
},
{
Clave: "belleza",
Nombre: "Peluquería / estética",
Descripcion: "Servicios, precios, turnos y políticas de cancelación.",
Tono: "amable y cercano",
Bienvenida: "¡Hola! ¿Querés reservar un turno o consultar precios?",
Notas: []NotaPlanura{
{
Titulo: "Servicios y precios",
Contenido: `[Corte]: desde [$X] — dura [N] minutos.
[Color]: desde [$X] — dura [N] minutos.
[Otros servicios con precio y duración].
Los precios varían según [largo de pelo / cantidad de producto]; si preguntan
por un caso puntual, ofrecé una consulta previa sin cargo.`,
},
{
Titulo: "Turnos y cancelaciones",
Contenido: `Trabajamos con turno previo, de [lunes a sábado] de [horario].
Se reserva por [WhatsApp].
Si no podés venir, avisá con [N horas] de anticipación.
[Cobramos / no cobramos] seña para [servicios largos].
Llegar más de [N] minutos tarde puede significar reprogramar el turno.`,
},
{
Titulo: "Cuidados previos y posteriores",
Contenido: `Para [color / alisado], venir con el pelo [lavado / sin lavar].
Después de [servicio], no [lavarse el pelo / mojarse] por [N] horas.
Recomendamos [productos] para mantener el resultado.`,
},
},
},
}
// PlantillaRubroPorClave busca una plantilla del catálogo.
func PlantillaRubroPorClave(clave string) (*PlantillaRubro, bool) {
for i := range PlantillasRubro {
if PlantillasRubro[i].Clave == clave {
return &PlantillasRubro[i], true
}
}
return nil, false
}
// AplicarPlantillaRubro carga las notas de una plantilla como conocimiento del
// agente. Cada nota entra como una fuente de tipo "texto" editable: el dueño
// abre, reemplaza los corchetes por sus datos y guarda.
//
// Se ejecuta en segundo plano porque cada nota necesita sus embeddings, y el
// agente ya se creó — hacer esperar la respuesta del alta por esto sería
// castigar al que elige plantilla.
func AplicarPlantillaRubro(agenteID uint, clave string) {
plantilla, ok := PlantillaRubroPorClave(clave)
if !ok {
log.Printf("[UMIND] Plantilla de rubro %q no existe, el agente %d queda vacío", clave, agenteID)
return
}
for _, nota := range plantilla.Notas {
doc := &models.UmindDocumento{
AgenteID: agenteID,
Tipo: "texto",
Origen: nota.Titulo,
Contenido: nota.Contenido,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
log.Printf("[UMIND] No se pudo crear la nota %q del agente %d: %v", nota.Titulo, agenteID, err)
continue
}
IngestarTexto(agenteID, doc.ID, nota.Contenido)
}
log.Printf("[UMIND] Agente %d arrancó con la plantilla %q (%d notas)", agenteID, clave, len(plantilla.Notas))
}
// ResumenPlantillasRubro devuelve el catálogo sin el contenido completo de las
// notas — es lo que necesita la pantalla de elección para mostrar las opciones.
func ResumenPlantillasRubro() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(PlantillasRubro))
for _, p := range PlantillasRubro {
titulos := make([]string, 0, len(p.Notas))
for _, n := range p.Notas {
titulos = append(titulos, n.Titulo)
}
out = append(out, map[string]interface{}{
"clave": p.Clave,
"nombre": p.Nombre,
"descripcion": p.Descripcion,
"notas": len(p.Notas),
"titulos": titulos,
"resumen": strings.Join(titulos, " · "),
})
}
return out
}
@@ -0,0 +1,89 @@
package services
import (
"strings"
"testing"
)
// El catálogo es contenido que se le muestra al cliente y que además define el
// conocimiento inicial de su agente. Un título vacío o una nota sin marcadores
// para completar significa que alguien va a publicar datos de ejemplo como si
// fueran reales.
func TestCatalogoDeRubrosEstaCompleto(t *testing.T) {
if len(PlantillasRubro) < 3 {
t.Fatalf("el catálogo tiene %d plantillas, muy pocas para ofrecer", len(PlantillasRubro))
}
vistas := map[string]bool{}
for _, p := range PlantillasRubro {
if p.Clave == "" || p.Nombre == "" || p.Descripcion == "" {
t.Errorf("plantilla incompleta: %+v", p.Clave)
}
if vistas[p.Clave] {
t.Errorf("clave duplicada: %q — la segunda nunca se podría elegir", p.Clave)
}
vistas[p.Clave] = true
if len(p.Notas) == 0 {
t.Errorf("la plantilla %q no trae ninguna nota: elegirla sería igual que no elegir nada", p.Clave)
}
for _, n := range p.Notas {
if strings.TrimSpace(n.Titulo) == "" {
t.Errorf("nota sin título en %q", p.Clave)
}
if len(strings.TrimSpace(n.Contenido)) < 40 {
t.Errorf("nota %q de %q demasiado corta: no generaría ni un fragmento", n.Titulo, p.Clave)
}
}
}
}
// Los valores de ejemplo van entre corchetes justamente para que se note que
// hay que reemplazarlos. Una nota sin corchetes se lee como dato real y termina
// en boca del asistente.
func TestLasNotasPidenSerCompletadas(t *testing.T) {
for _, p := range PlantillasRubro {
for _, n := range p.Notas {
if !strings.Contains(n.Contenido, "[") {
t.Errorf("la nota %q de %q no tiene ningún marcador [entre corchetes]: se puede confundir con información real", n.Titulo, p.Clave)
}
}
}
}
// El rubro de salud no puede ofrecerse sin decir explícitamente que el
// asistente no da consejo médico: es el único donde una respuesta inventada
// hace daño de verdad.
func TestSaludAclaraQueNoDaConsejoMedico(t *testing.T) {
p, ok := PlantillaRubroPorClave("salud")
if !ok {
t.Fatal("falta la plantilla de salud")
}
junto := strings.ToLower(p.Notas[0].Titulo + " " + p.Notas[0].Contenido)
if !strings.Contains(junto, "no responde consultas médicas") && !strings.Contains(junto, "no damos consejo") {
t.Error("la primera nota de salud tiene que ser el límite: el asistente no da consejo médico")
}
}
func TestPlantillaRubroPorClave(t *testing.T) {
if _, ok := PlantillaRubroPorClave("no-existe"); ok {
t.Error("una clave inventada no debería resolver")
}
p, ok := PlantillaRubroPorClave("restaurante")
if !ok || p.Nombre == "" {
t.Error("restaurante debería existir en el catálogo")
}
}
func TestResumenNoFiltraElContenido(t *testing.T) {
// El resumen alimenta la pantalla de elección: manda títulos, no el texto
// completo de cada nota.
for _, r := range ResumenPlantillasRubro() {
if _, hay := r["contenido"]; hay {
t.Error("el resumen no debería incluir el contenido de las notas")
}
if r["clave"] == "" || r["nombre"] == "" {
t.Errorf("resumen incompleto: %+v", r)
}
}
}