Files
Lizandro GuarnizoandClaude Opus 5 3ea17b0980 feat(umind): cargar conocimiento de tres formas y que deje de quedar viejo en silencio
Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:

Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.

Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.

Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.

De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:45:41 -05:00

349 lines
13 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 | 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"`
}
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
}
// 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
}