feat: bucles de crecimiento y activación de uMind

Cuatro cosas que ya estaban a medio construir y no se usaban como canal.

1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
   estaba dando exposición en su sitio y no se capitalizaba. El host sale
   de la URL del propio script, así funciona igual en cualquier entorno.

2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
   las abrió el visitante, y el consumo desglosado. Es lo que el cliente
   necesita para justificar el gasto puertas adentro — un panel al que hay
   que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
   generador de xlsx del cronograma.

3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
   agente sin fuentes responde de memoria e inventa datos, el cliente
   concluye que el producto no sirve y se va. Es el punto donde más gente
   se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
   auditoría como registro de envío para no necesitar tabla nueva ni
   convertir el recordatorio en spam.

4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
   tiene, oferta si no. Es el punto de contacto más barato que hay — ya
   entró, ya confía, y el cobro se suma a la factura que ya recibe. El
   mensaje lidera con notas de voz y fotos, que es lo más difícil de
   copiar de lo que tenemos.

La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 22:40:28 -05:00
co-authored by Claude Sonnet 5
parent cfa91a5c09
commit 8ef49c5169
12 changed files with 342 additions and 38 deletions
+21 -5
View File
@@ -165,6 +165,15 @@ const showCanalForm = ref(false)
const canalForm = ref(canalVacio())
const widgetCopiado = ref(false)
// El reporte se descarga con una navegación normal (no fetch): así el
// navegador maneja el archivo y la cookie de sesión viaja sola.
const urlReporte = computed(() => {
const hoy = new Date()
const desde = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10)
const hasta = hoy.toISOString().slice(0, 10)
return apiUmind(`/umind/reporte.xlsx?agente_id=${props.agenteId}&desde=${desde}&hasta=${hasta}`)
})
const widgetSnippet = computed(() => {
const siteKey = agente.value?.site_key || 'TU_SITE_KEY'
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
@@ -334,11 +343,18 @@ onMounted(async () => {
<div>
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-6 mt-1">
<h1 class="text-xl font-semibold text-texto">{{ agente.nombre }}</h1>
<p class="text-xs text-tenue mt-1">
site_key: <code class="bg-elevado px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
<div v-if="agente" class="mb-6 mt-1 flex items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-xl font-semibold text-texto">{{ agente.nombre }}</h1>
<p class="text-xs text-tenue mt-1">
site_key: <code class="bg-elevado px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
</div>
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
Reporte del mes
</a>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
+38
View File
@@ -1,6 +1,8 @@
package models
import (
"time"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -268,3 +270,39 @@ func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
`, agenteID, limit).Scan(&items).Error
return items, err
}
// SesionResumen es una conversación vista desde el reporte: cuándo empezó,
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
type SesionResumen struct {
SessionID string `json:"session_id"`
Inicio time.Time `json:"inicio"`
Mensajes int64 `json:"mensajes"`
PrimerMensaje string `json:"primer_mensaje"`
}
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
// rango. Una sola consulta: el primer mensaje del visitante se saca con una
// subconsulta correlacionada en vez de traer todos los mensajes y agrupar en
// Go, que en un agente con tráfico sería traerse el historial entero.
func GetUmindSesionesRango(agenteID uint, desde, hasta time.Time) ([]SesionResumen, error) {
var items []SesionResumen
err := app.Http.Database.DB.Raw(`
SELECT
m.session_id,
MIN(m.created_at) AS inicio,
COUNT(*) AS mensajes,
COALESCE((
SELECT p.content FROM umind_mensajes p
WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id
AND p.role = 'user' AND p.deleted_at IS NULL
ORDER BY p.created_at ASC LIMIT 1
), '') AS primer_mensaje
FROM umind_mensajes m
WHERE m.agente_id = ? AND m.created_at >= ? AND m.created_at < ? AND m.deleted_at IS NULL
GROUP BY m.session_id, m.agente_id
ORDER BY inicio DESC
LIMIT 2000
`, agenteID, desde, hasta).Scan(&items).Error
return items, err
}
+8
View File
@@ -49,6 +49,14 @@ func IniciarCron() {
return
}
// Agentes de uMind creados pero sin base de conocimiento — una vez al día
// a las 10:00. Es el punto donde más clientes se quedan a mitad de camino,
// y se detecta solo.
if _, err := cronScheduler.AddFunc("0 10 * * *", AvisarAgentesSinConocimiento); err != nil {
log.Printf("[CRON] Error registrando tarea umind_activacion: %v", err)
return
}
// Purga de historial de métricas — cada noche a las 3 AM (retención 7 días)
if _, err := cronScheduler.AddFunc("0 3 * * *", func() {
if err := models.PurgarMetricasHistory(7); err != nil {
+108
View File
@@ -0,0 +1,108 @@
package services
import (
"fmt"
"log"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Un agente creado pero sin fuentes de conocimiento no le sirve a nadie:
// responde de memoria, inventa datos, y el cliente concluye que el producto no
// funciona. Es el punto donde más gente se cae, y se detecta solo — así que en
// vez de esperar a que alguien lo note, se avisa.
//
// El aviso se manda UNA sola vez por agente (UmindEventoLog hace de registro,
// sin tabla nueva): insistir sobre algo que el cliente ya decidió ignorar
// convierte un recordatorio útil en spam.
const origenActivacion = "activacion"
// diasParaAvisar es la espera antes del aviso. Lo suficiente para no molestar
// a quien está configurando el agente en ese momento.
const diasParaAvisar = 3
// AvisarAgentesSinConocimiento recorre los agentes activos que llevan varios
// días sin ninguna fuente cargada y le escribe al cliente dueño.
func AvisarAgentesSinConocimiento() {
db := app.Http.Database.DB
corte := time.Now().AddDate(0, 0, -diasParaAvisar)
var agentes []models.UmindAgente
if err := db.Where("activo = ? AND created_at < ? AND deleted_at IS NULL", true, corte).
Find(&agentes).Error; err != nil {
log.Printf("[ACTIVACION] no se pudieron listar los agentes: %v", err)
return
}
for _, a := range agentes {
var docs int64
db.Model(&models.UmindDocumento{}).
Where("agente_id = ? AND deleted_at IS NULL", a.ID).Count(&docs)
if docs > 0 {
continue
}
if yaSeAviso(a.ID) {
continue
}
if enviarAvisoSinConocimiento(&a) {
models.RegistrarEventoUmind(a.ID, "warn", origenActivacion,
"Se avisó al cliente que el agente no tiene base de conocimiento",
fmt.Sprintf("El agente lleva más de %d días activo sin ninguna fuente cargada.", diasParaAvisar))
}
}
}
// yaSeAviso usa el log de auditoría como registro de envío: el evento que
// deja enviarAvisoSinConocimiento es la marca de "ya se mandó".
func yaSeAviso(agenteID uint) bool {
var n int64
app.Http.Database.DB.Model(&models.UmindEventoLog{}).
Where("agente_id = ? AND origen = ?", agenteID, origenActivacion).Count(&n)
return n > 0
}
// enviarAvisoSinConocimiento devuelve true solo si el correo salió, para no
// marcar como avisado a alguien que nunca recibió nada.
func enviarAvisoSinConocimiento(a *models.UmindAgente) bool {
tenant, err := models.GetUmindTenantByID(a.TenantID)
if err != nil || tenant.ClienteID == nil {
return false // sin cliente asignado no hay a quién escribirle
}
destinatarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID)
if err != nil || len(destinatarios) == 0 {
return false
}
url := GetPublicURL() + "/portal/studio"
asunto := fmt.Sprintf("Tu asistente %q todavía no tiene información cargada", a.Nombre)
enviadoAlguno := false
for _, u := range destinatarios {
if u.Email == "" {
continue
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<div style="text-align:center;margin-bottom:24px">
<div style="width:48px;height:48px;border-radius:50%%;background:#8eb02f;display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:20px">U</div>
<h2 style="margin:12px 0 4px;color:#1e293b">Falta un paso para que tu asistente responda bien</h2>
<p style="color:#64748b;font-size:14px;margin:0">uMind Studio · U-site</p>
</div>
<p style="color:#334155;font-size:15px">Hola <strong>%s</strong>,</p>
<p style="color:#334155;font-size:14px">Tu asistente <strong>%s</strong> ya está creado, pero todavía no tiene ninguna fuente de información cargada.</p>
<p style="color:#334155;font-size:14px">Sin eso solo puede dar respuestas genéricas: no conoce tus precios, tus horarios ni tus servicios. Cargarle tu sitio web toma un minuto y es lo que hace la diferencia entre un chat que responde cualquier cosa y uno que de verdad atiende.</p>
<a href="%s" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px;margin:24px 0">Cargar la información</a>
<p style="color:#64748b;font-size:13px">¿Preferís que lo hagamos nosotros? Respondé este correo con la dirección de tu sitio y lo dejamos listo.</p>
</div></body></html>`, u.Nombre, a.Nombre, url)
if err := app.Http.Mail.Send(u.Email, asunto, html); err != nil {
log.Printf("[ACTIVACION] no se pudo avisar a %s por el agente %d: %v", u.Email, a.ID, err)
continue
}
enviadoAlguno = true
}
return enviadoAlguno
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-xrbA4kuN.js"></script>
<script type="module" crossorigin src="/orchestrator/assets/index-CXtokcMG.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DyYio0bD.css">
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
+30
View File
@@ -23,6 +23,36 @@
</div>
{{ end }}
<!-- uMind: atajo si ya lo tiene, oferta si no. El dueño ya entró y ya
confía; es el punto de contacto más barato que existe. -->
{{ if .tieneUmind }}
<a href="/portal/studio" class="flex items-center gap-3 bg-white border border-slate-200 hover:border-[#8eb02f] rounded-2xl p-4 mb-6 transition-colors group">
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span>
<div class="min-w-0 flex-1">
<p class="font-semibold text-slate-800 text-sm">uMind Studio</p>
<p class="text-slate-500 text-xs mt-0.5">Configurá tus asistentes, revisá conversaciones y consumo.</p>
</div>
<span class="text-slate-300 group-hover:text-[#8eb02f] transition-colors">&rarr;</span>
</a>
{{ else }}
<div class="rounded-2xl p-5 mb-6 border border-slate-200 bg-gradient-to-br from-[#8eb02f]/8 to-transparent">
<div class="flex items-start gap-3">
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span>
<div class="min-w-0">
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
<p class="text-slate-600 text-sm mt-1">
Un asistente que contesta con la información de tu negocio — precios, horarios, servicios.
Entiende las <strong>notas de voz</strong> y lee las <strong>fotos</strong> que te mandan tus clientes.
</p>
<a href="mailto:{{ if .portalUser }}{{ end }}soporte@u-site.app?subject=Quiero%20activar%20uMind"
class="inline-block mt-3 text-sm font-semibold text-white rounded-lg px-4 py-2" style="background:#8eb02f">
Quiero activarlo
</a>
</div>
</div>
</div>
{{ end }}
<!-- Proyectos cards -->
{{ if .isPartner }}
<!-- Agrupado por cliente -->
@@ -113,7 +113,10 @@ const umindWidgetJS = `(function () {
var script = document.currentScript;
var siteKey = script.getAttribute('data-site');
if (!siteKey) { console.error('[uMind] falta data-site en el <script>'); return; }
var apiBase = script.src.replace(/\/widget\/umind\.js.*$/, '') + '/widget/' + siteKey;
// El origen sale de la URL del propio script: así el badge apunta al mismo
// host que sirve el widget, sin tener que inyectar la URL desde el backend.
var origen = script.src.replace(/\/widget\/umind\.js.*$/, '');
var apiBase = origen + '/widget/' + siteKey;
var storageKey = 'umind_session_' + siteKey;
var color = '#8eb02f';
@@ -140,7 +143,9 @@ const umindWidgetJS = `(function () {
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
'.umind-inputbar button svg{width:16px;height:16px;}',
'.umind-badge{text-align:center;font-size:10.5px;color:#aaa;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
'.umind-badge{text-align:center;font-size:10.5px;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
'.umind-badge a{color:#aaa;text-decoration:none;transition:color .15s;}',
'.umind-badge a:hover{color:#666;}',
].join('');
document.head.appendChild(style);
@@ -172,7 +177,10 @@ const umindWidgetJS = `(function () {
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." />' +
'<button id="umind-send" aria-label="Enviar"></button>' +
'</div>' +
'<div class="umind-badge">&#10022; Impulsado por uMind</div>';
'<div class="umind-badge">' +
'<a href="' + origen + '/?utm_source=widget&utm_medium=badge&utm_campaign=umind" ' +
'target="_blank" rel="noopener">&#10022; Impulsado por uMind</a>' +
'</div>';
document.body.appendChild(panel);
panel.querySelector('#umind-send').innerHTML = sendSVG;
+6
View File
@@ -166,6 +166,11 @@ func PortalDashboard(c *fiber.Ctx) error {
}
models.AplicarProgreso(proyectos)
// Si el cliente todavía no tiene uMind, el dashboard es el mejor lugar
// para ofrecérselo: ya entró, ya confía, y el cobro se suma a la factura
// que ya recibe. Si ya lo tiene, se le ofrece el atajo en vez del pitch.
tenantsUmind, _ := models.GetUmindTenantsByClientes(clienteIDs)
// Si partner: agrupar proyectos por cliente
type ClienteProyectos struct {
Cliente models.Cliente
@@ -191,6 +196,7 @@ func PortalDashboard(c *fiber.Ctx) error {
"proyectos": proyectos,
"grupos": grupos,
"isPartner": fullUser.EsPartner(),
"tieneUmind": len(tenantsUmind) > 0,
}, "layouts/portal")
}
+92 -3
View File
@@ -1,6 +1,7 @@
package controllers
import (
"bytes"
"fmt"
"math"
"regexp"
@@ -164,9 +165,6 @@ func GetUmindAgentesHandler(c *fiber.Ctx) error {
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -767,3 +765,94 @@ func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
}
return desde, hasta
}
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
// al portal no sirve para eso; un archivo que se reenvía, sí.
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
func GetUmindReporteHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
}
agente, errAcceso := accesoAgente(c, uint(agenteID))
if errAcceso != nil {
return errAcceso
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
negocio := ""
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
negocio = t.Nombre
}
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
filas := [][]string{
{"Reporte de actividad"},
{"Negocio", negocio},
{"Agente", agente.Nombre},
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
{},
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
{},
{"Consumo del período"},
{"Tipo", "Cantidad", "Unidad", "Usos"},
}
etiquetas := map[string]string{
models.UsoTipoIA: "Inteligencia artificial",
models.UsoTipoOCR: "Lectura de imágenes",
models.UsoTipoWhisper: "Transcripción de audio",
}
for _, r := range resumen {
nombre := etiquetas[r.Tipo]
if nombre == "" {
nombre = r.Tipo
}
filas = append(filas, []string{
nombre,
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
r.Unidad,
strconv.FormatInt(r.Eventos, 10),
})
}
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
for _, s := range sesiones {
filas = append(filas, []string{
s.Inicio.Format("02/01/2006 15:04"),
strconv.FormatInt(s.Mensajes, 10),
s.PrimerMensaje,
})
}
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
var buf bytes.Buffer
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
return c.Status(500).SendString("no se pudo generar el reporte")
}
return c.Send(buf.Bytes())
}
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
// acentos o espacios rompe el filename en algunos navegadores.
func slugSimple(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "agente"
}
return out
}
+1
View File
@@ -63,4 +63,5 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)
g.Get("/umind/uso", r(controllers.GetUmindUsoHandler)...)
g.Get("/umind/ai-configs", r(controllers.GetUmindAiConfigsHandler)...)
g.Get("/umind/reporte.xlsx", r(controllers.GetUmindReporteHandler)...)
}