Dos mitades de la misma experiencia: el primer día y todos los días después. El primer día. Un agente recién creado mostraba tres avisos ámbar sueltos —"no atiende", "no sabe nada"— sin ningún orden: el dueño sabía que faltaban cosas pero no cuál iba primero. Ahora hay una guía de tres pasos (contale de tu negocio → hacele una pregunta → ponelo a atender) donde solo el siguiente paso pendiente se resalta, porque una guía donde todo grita a la vez no guía nada. Mientras falte algo, la guía reemplaza al estado — una sola voz por etapa; con los pasos hechos desaparece para siempre y el estado operativo toma su lugar. Todos los días. La bandeja de conversaciones mostraba el último mensaje de cada sesión —casi siempre la respuesta del bot, "¡Hola! ¿En qué te ayudo?" cincuenta veces— y el session_id crudo (tg:8812). Ahora cada conversación muestra lo que el visitante preguntó al abrir, que es el dato que le importa al dueño: qué le preguntan de verdad a su negocio. Con canal deducido del prefijo de la sesión (WhatsApp, Telegram, Tu web, Prueba), cantidad de mensajes, y fecha relativa. Las pruebas propias se separan detrás de un filtro: mezcladas inflan la lista y el dueño no distingue cuáles son clientes reales. El contador del estado cuenta solo las reales, por lo mismo — si arriba dice 4 y la lista muestra 3, el número parece roto. El hilo muestra la hora de cada mensaje, y el backend gana una consulta que agrupa por sesión con el primer mensaje del visitante en vez de mandar filas crudas; la vieja quedó sin usos y se fue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
15 KiB
Go
394 lines
15 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// UmindTenant representa un sitio/cliente de uMind — dueño de los dominios
|
|
// permitidos y el nombre del negocio que se muestra al visitante. Un tenant
|
|
// puede tener varios UmindAgente independientes (cada uno con su propia
|
|
// config de IA, base de conocimiento, tools y canales); lo que antes vivía
|
|
// acá (SiteKey, AiConfigID, Tono, MensajeBienvenida, Color) se movió a
|
|
// UmindAgente — ver pkg/models/umind_agente.go y migrations.MigrarUmindAgentes.
|
|
type UmindTenant struct {
|
|
gorm.Model
|
|
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
|
DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app
|
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
|
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
|
// ClienteID es lo que le permite a un PortalUser llegar a sus tenants
|
|
// (ver GetClienteIDsForPortalUser). Puntero y sin not null: los tenants
|
|
// creados antes de que existiera el portal quedan sin cliente hasta que
|
|
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
|
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
|
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
|
|
// UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se
|
|
// pasó el tope de consumo, para no repetirlo por cada mensaje. En la base
|
|
// y no en memoria: con despliegues varias veces al día, un flag en RAM se
|
|
// borra seguido y el cliente recibe el mismo aviso una y otra vez.
|
|
UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"`
|
|
}
|
|
|
|
// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve
|
|
// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo.
|
|
//
|
|
// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes
|
|
// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa.
|
|
func MarcarAvisoTope(tenantID uint, periodo string) bool {
|
|
res := app.Http.Database.DB.Model(&UmindTenant{}).
|
|
Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo).
|
|
Update("ultimo_aviso_tope", periodo)
|
|
return res.Error == nil && res.RowsAffected > 0
|
|
}
|
|
|
|
func (UmindTenant) TableName() string { return "umind_tenants" }
|
|
|
|
// GenerarSiteKey crea un identificador público único para el widget de un
|
|
// agente. No se hashea (a diferencia de un token de API) porque no es un
|
|
// secreto: viaja en el HTML público del sitio del cliente.
|
|
func GenerarSiteKey() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("no se pudo generar la site_key: %w", err)
|
|
}
|
|
return "umk_" + hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func CreateUmindTenant(t *UmindTenant) error {
|
|
return app.Http.Database.DB.Create(t).Error
|
|
}
|
|
|
|
func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
|
|
var items []UmindTenant
|
|
var total int64
|
|
db := app.Http.Database.DB.Model(&UmindTenant{})
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
// GetUmindTenantsByClientes lista los tenants que pertenecen a alguno de los
|
|
// clientes dados — es la base del alcance del portal. Lista vacía devuelve
|
|
// vacío sin consultar (fail-closed: un portal user sin clientes no ve nada).
|
|
func GetUmindTenantsByClientes(clienteIDs []uint) ([]UmindTenant, error) {
|
|
if len(clienteIDs) == 0 {
|
|
return []UmindTenant{}, nil
|
|
}
|
|
var items []UmindTenant
|
|
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("id DESC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func GetUmindTenantByID(id uint) (*UmindTenant, error) {
|
|
var t UmindTenant
|
|
if err := app.Http.Database.DB.First(&t, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func UpdateUmindTenant(id uint, updates map[string]interface{}) error {
|
|
return app.Http.Database.DB.Model(&UmindTenant{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
func DeleteUmindTenant(id uint) error {
|
|
return app.Http.Database.DB.Delete(&UmindTenant{}, id).Error
|
|
}
|
|
|
|
// DominioPermitido valida el host de un Origin/Referer contra la lista
|
|
// configurada. Admite dominio exacto o comodín "*.dominio.com" para
|
|
// subdominios. Lista vacía = no permite nada (fail-closed): un tenant recién
|
|
// creado sin dominios configurados no debe poder ser usado desde ningún sitio.
|
|
func (t *UmindTenant) DominioPermitido(host string) bool {
|
|
host = strings.ToLower(strings.TrimSpace(host))
|
|
if host == "" {
|
|
return false
|
|
}
|
|
lista := strings.TrimSpace(t.DominiosPermitidos)
|
|
if lista == "" {
|
|
return false
|
|
}
|
|
for _, entrada := range strings.Split(lista, ",") {
|
|
entrada = strings.ToLower(strings.TrimSpace(entrada))
|
|
if entrada == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(entrada, "*.") {
|
|
sufijo := entrada[1:] // ".dominio.com"
|
|
if strings.HasSuffix(host, sufijo) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if entrada == host {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ─── Documentos y chunks de conocimiento ────────────────────────────────────
|
|
|
|
// UmindDocumento es una fuente de conocimiento de un agente: una URL
|
|
// crawleada o un archivo subido. Se trocea en UmindChunk para la búsqueda
|
|
// por similitud.
|
|
type UmindDocumento struct {
|
|
gorm.Model
|
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
|
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
|
|
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
|
|
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
|
Error string `json:"error" gorm:"column:error;type:text"`
|
|
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
|
// Contenido guarda el texto de las fuentes que no se pueden volver a
|
|
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
|
|
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
|
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
|
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
|
|
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
|
|
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
|
|
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
|
|
// que delata que la fuente quedó vieja.
|
|
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
|
|
// AutoActualizar deja que el cron la vuelva a procesar sola.
|
|
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
|
|
}
|
|
|
|
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
|
|
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
|
|
// pintar una lista entera.
|
|
func NombresDeTenantsUmind() map[uint]string {
|
|
var filas []UmindTenant
|
|
out := map[uint]string{}
|
|
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
|
|
return out
|
|
}
|
|
for _, f := range filas {
|
|
out[f.ID] = f.Nombre
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
|
|
|
func CreateUmindDocumento(d *UmindDocumento) error {
|
|
return app.Http.Database.DB.Create(d).Error
|
|
}
|
|
|
|
func GetUmindDocumentosByAgente(agenteID uint) ([]UmindDocumento, error) {
|
|
var items []UmindDocumento
|
|
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
|
|
var d UmindDocumento
|
|
if err := app.Http.Database.DB.First(&d, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
|
updates := map[string]interface{}{
|
|
"estado": estado,
|
|
"error": errMsg,
|
|
"total_chunks": totalChunks,
|
|
}
|
|
if estado == "listo" {
|
|
ahora := time.Now()
|
|
updates["procesado_at"] = &ahora
|
|
}
|
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
|
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
|
|
// se procesan desde hace más de los días indicados.
|
|
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
|
|
var items []UmindDocumento
|
|
corte := time.Now().AddDate(0, 0, -dias)
|
|
err := app.Http.Database.DB.
|
|
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
|
|
Where("procesado_at IS NULL OR procesado_at < ?", corte).
|
|
Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func DeleteUmindDocumento(id uint) error {
|
|
if err := app.Http.Database.DB.Where("documento_id = ?", id).Delete(&UmindChunk{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return app.Http.Database.DB.Delete(&UmindDocumento{}, id).Error
|
|
}
|
|
|
|
// UmindChunk es un fragmento de texto con su embedding, listo para búsqueda
|
|
// por similitud. Sin pgvector por ahora: el embedding se guarda como JSON de
|
|
// []float32 y la similitud se calcula en memoria (suficiente para el volumen
|
|
// de un piloto de un solo agente; si el volumen crece, se migra a pgvector
|
|
// sin cambiar la interfaz de búsqueda).
|
|
type UmindChunk struct {
|
|
gorm.Model
|
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
|
DocumentoID uint `json:"documento_id" gorm:"column:documento_id;index;not null"`
|
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text;not null"`
|
|
EmbeddingJSON string `json:"-" gorm:"column:embedding_json;type:text"`
|
|
}
|
|
|
|
func (UmindChunk) TableName() string { return "umind_chunks" }
|
|
|
|
// EmbeddingToJSON / EmbeddingFromJSON convierten el vector a/desde el formato
|
|
// de almacenamiento en texto.
|
|
func EmbeddingToJSON(v []float32) (string, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func EmbeddingFromJSON(s string) ([]float32, error) {
|
|
var v []float32
|
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
|
return nil, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
|
|
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
|
|
// en la búsqueda, y la vieja puede ganar.
|
|
func BorrarChunksDeDocumento(documentoID uint) error {
|
|
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
|
|
}
|
|
|
|
func CreateUmindChunks(chunks []UmindChunk) error {
|
|
if len(chunks) == 0 {
|
|
return nil
|
|
}
|
|
return app.Http.Database.DB.CreateInBatches(chunks, 50).Error
|
|
}
|
|
|
|
// GetUmindChunksByAgente retorna todos los chunks del agente, para la
|
|
// búsqueda por similitud en memoria.
|
|
func GetUmindChunksByAgente(agenteID uint) ([]UmindChunk, error) {
|
|
var items []UmindChunk
|
|
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// ─── Historial de conversación del widget ───────────────────────────────────
|
|
|
|
// UmindMensaje guarda el historial de conversación del widget, por agente y
|
|
// sesión de navegador (no hay usuario autenticado del lado del visitante).
|
|
type UmindMensaje struct {
|
|
gorm.Model
|
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
|
SessionID string `json:"session_id" gorm:"column:session_id;index;not null"`
|
|
Role string `json:"role" gorm:"column:role;not null"` // user | assistant
|
|
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
|
}
|
|
|
|
func (UmindMensaje) TableName() string { return "umind_mensajes" }
|
|
|
|
func SaveUmindMensaje(agenteID uint, sessionID, role, content string) error {
|
|
m := &UmindMensaje{AgenteID: agenteID, SessionID: sessionID, Role: role, Content: content}
|
|
return app.Http.Database.DB.Create(m).Error
|
|
}
|
|
|
|
// GetUmindHistorial retorna los últimos n mensajes de una sesión, en orden cronológico.
|
|
func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje, error) {
|
|
var items []UmindMensaje
|
|
err := app.Http.Database.DB.
|
|
Where("agente_id = ? AND session_id = ?", agenteID, sessionID).
|
|
Order("created_at DESC").
|
|
Limit(n).
|
|
Find(&items).Error
|
|
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
|
items[i], items[j] = items[j], items[i]
|
|
}
|
|
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"`
|
|
// Ultimo es la última actividad: es lo que ordena la bandeja del panel.
|
|
// El reporte mensual no lo usa (ordena por inicio) y ahí queda en cero.
|
|
Ultimo time.Time `json:"ultimo"`
|
|
}
|
|
|
|
// GetUmindSesionesPanel es la bandeja de conversaciones del panel: las más
|
|
// recientes primero, con lo que el visitante preguntó al abrir — no con la
|
|
// última respuesta del bot, que era lo que se mostraba antes y no decía nada
|
|
// ("¡Hola! ¿En qué te ayudo?" cincuenta veces).
|
|
func GetUmindSesionesPanel(agenteID uint, limit int) ([]SesionResumen, error) {
|
|
var items []SesionResumen
|
|
err := app.Http.Database.DB.Raw(`
|
|
SELECT
|
|
m.session_id,
|
|
MIN(m.created_at) AS inicio,
|
|
MAX(m.created_at) AS ultimo,
|
|
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.deleted_at IS NULL
|
|
GROUP BY m.session_id, m.agente_id
|
|
ORDER BY ultimo DESC
|
|
LIMIT ?
|
|
`, agenteID, limit).Scan(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// 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
|
|
}
|