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:
co-authored by
Claude Opus 5
parent
3ea17b0980
commit
af71b66bb8
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user