Una fuente crawleada decía "9 fragmentos" y nada más. Las notas y archivos al menos tienen Editar, que muestra su texto; un sitio no tiene nada — era una caja negra, sin forma de saber si el crawler leyó los precios o el pie de página. Y cuando el agente contesta mal, la primera pregunta es justamente qué tiene cargado de verdad. Cada fuente gana un "Ver" que abre sus fragmentos tal como quedaron indexados, numerados y en orden, con la advertencia que importa: esto es exactamente lo que el asistente puede consultar — si acá falta algo, eso mismo le va a faltar en las respuestas. El botón se apaga cuando la fuente no tiene fragmentos todavía (procesando o con error): abrir un visor vacío no informa nada. El endpoint pasa por accesoDocumento como todos los sub-recursos, y devuelve solo el contenido — el embedding no viaja: son miles de números que no le sirven a nadie en pantalla. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
16 KiB
Go
407 lines
16 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.
|
|
// GetUmindChunksByDocumento devuelve los fragmentos de UNA fuente, en el orden
|
|
// en que se guardaron. Es lo que deja ver qué se leyó de verdad de un sitio o
|
|
// un archivo: sin esto, una URL crawleada era una caja negra — "9 fragmentos"
|
|
// y ninguna forma de saber si el crawler leyó los precios o el pie de página.
|
|
func GetUmindChunksByDocumento(documentoID uint) ([]UmindChunk, error) {
|
|
var items []UmindChunk
|
|
err := app.Http.Database.DB.
|
|
Where("documento_id = ?", documentoID).
|
|
Order("id ASC").
|
|
Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
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
|
|
}
|