Files
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
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>
2026-08-17 19:45:38 -05:00

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
}