En cada ticket, "✨ Borrador" propone una respuesta usando los fragmentos más parecidos de la base de conocimiento del agente de uMind que se elija en la configuración de soporte, más el hilo de la conversación. El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que nadie lo lea, es la forma más rápida de perderlo. Sin agente configurado el borrador se arma igual, solo con la conversación — peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o pasos que no estén en la documentación, y le pide decir qué falta preguntar cuando no alcanza para resolver. El cuadro de respuesta pasa a textarea: un borrador de varios renglones no entraba en un input de una línea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.3 KiB
Go
154 lines
5.3 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// UmindAgente es un agente de IA independiente dentro de un tenant — un
|
|
// mismo negocio puede tener varios (ej. "Ventas", "Soporte"), cada uno con
|
|
// su propia base de conocimiento, tools, canales y conexión de correo. Lo
|
|
// único que sigue siendo del tenant (no del agente) son los dominios
|
|
// permitidos y el nombre del negocio que se muestra al visitante — eso es
|
|
// del sitio, no de un agente puntual.
|
|
type UmindAgente struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
|
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` // etiqueta interna, ej: "Ventas"
|
|
SiteKey string `json:"site_key" gorm:"column:site_key;uniqueIndex;size:40;not null"`
|
|
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
|
|
Tono string `json:"tono" gorm:"column:tono;type:text"`
|
|
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
|
|
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"`
|
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
|
}
|
|
|
|
func (UmindAgente) TableName() string { return "umind_agentes" }
|
|
|
|
func CreateUmindAgente(a *UmindAgente) error {
|
|
if a.SiteKey == "" {
|
|
key, err := GenerarSiteKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.SiteKey = key
|
|
}
|
|
if a.Color == "" {
|
|
a.Color = "#8eb02f"
|
|
}
|
|
return app.Http.Database.DB.Create(a).Error
|
|
}
|
|
|
|
func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
|
var items []UmindAgente
|
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id ASC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
|
|
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
|
|
// conocimiento salen los borradores de soporte).
|
|
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
|
|
var items []UmindAgente
|
|
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// GetUmindAgenteBySiteKey resuelve el agente a partir de la site_key pública
|
|
// que manda el widget. Solo hace match si el agente está activo.
|
|
func GetUmindAgenteBySiteKey(siteKey string) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.Where("site_key = ? AND activo = ?", siteKey, true).First(&a).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
func UpdateUmindAgente(id uint, updates map[string]interface{}) error {
|
|
return app.Http.Database.DB.Model(&UmindAgente{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
func DeleteUmindAgente(id uint) error {
|
|
return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error
|
|
}
|
|
|
|
// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por
|
|
// activo, para que el llamador pueda distinguir "no existe" de "está
|
|
// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para
|
|
// quien solo necesita el camino feliz.
|
|
func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
|
|
// un agente está listo o le falta algo, sin entrar a configurarlo.
|
|
type ResumenAgente struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Documentos int64 `json:"documentos"`
|
|
Canales int64 `json:"canales"`
|
|
Conversaciones7d int64 `json:"conversaciones_7d"`
|
|
}
|
|
|
|
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
|
|
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
|
|
// N+1 en una pantalla de listado es el que después no se puede sacar.
|
|
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
|
|
out := make(map[uint]*ResumenAgente, len(agenteIDs))
|
|
for _, id := range agenteIDs {
|
|
out[id] = &ResumenAgente{AgenteID: id}
|
|
}
|
|
if len(agenteIDs) == 0 {
|
|
return out
|
|
}
|
|
db := app.Http.Database.DB
|
|
|
|
type fila struct {
|
|
AgenteID uint
|
|
Total int64
|
|
}
|
|
|
|
var docs []fila
|
|
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
|
|
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
|
|
for _, f := range docs {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Documentos = f.Total
|
|
}
|
|
}
|
|
|
|
var canales []fila
|
|
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
|
|
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
|
|
for _, f := range canales {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Canales = f.Total
|
|
}
|
|
}
|
|
|
|
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
|
|
var convs []fila
|
|
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
|
|
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
|
|
Group("agente_id").Scan(&convs)
|
|
for _, f := range convs {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Conversaciones7d = f.Total
|
|
}
|
|
}
|
|
return out
|
|
}
|