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>
309 lines
11 KiB
Go
309 lines
11 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"`
|
|
}
|
|
|
|
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
|
|
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo
|
|
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"`
|
|
}
|
|
|
|
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 {
|
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"estado": estado,
|
|
"error": errMsg,
|
|
"total_chunks": totalChunks,
|
|
}).Error
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// GetUmindSesiones lista las sesiones de conversación recientes de un agente
|
|
// (para el panel admin), con el último mensaje como resumen.
|
|
func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
|
|
var items []UmindMensaje
|
|
err := app.Http.Database.DB.Raw(`
|
|
SELECT * FROM (
|
|
SELECT DISTINCT ON (session_id) *
|
|
FROM umind_mensajes
|
|
WHERE agente_id = ? AND deleted_at IS NULL
|
|
ORDER BY session_id, created_at DESC
|
|
) ultimos
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`, 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
|
|
}
|