From 5ee5880dc2eb3cade5ae829385702ccb2ceb8a93 Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Fri, 7 Aug 2026 16:18:16 +0000 Subject: [PATCH] =?UTF-8?q?Agrega=20uMind:=20chat=20con=20IA=20embebible?= =?UTF-8?q?=20por=20tenant=20(F1=20=E2=80=94=20widget=20web=20+=20RAG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya existente (AiConfig, motor de function-calling del agente de Telegram) en vez de un servicio nuevo aparte: - UmindTenant: sitio/cliente con dominios permitidos, config de IA para el chat y personalidad/tono. - Ingesta: crawler simple (mismo dominio, N páginas) + chunking + embeddings (config global con módulo "umind_embeddings", pensada para OpenAI ya que Claude no ofrece embeddings) guardados como JSON, con búsqueda por similitud coseno en memoria (sin pgvector todavía). - Agente acotado: única herramienta buscar_conocimiento, sin acceso a nada interno — si no encuentra la respuesta, lo dice en vez de inventar. - Widget público (/widget/umind.js + /widget/:site_key/*), autenticado por site_key + validación de dominio (Origin/Referer), no por secreto, ya que la key viaja en el HTML público del sitio instalado. - Panel /app/umind: tenants, estado de ingesta, historial de conversaciones por sesión. --- go.mod | 4 +- main.go | 6 + migrations/migrate.go | 42 ++ pkg/models/ai_config.go | 23 ++ pkg/models/umind.go | 271 +++++++++++++ pkg/services/umind_agent_service.go | 143 +++++++ pkg/services/umind_embeddings_service.go | 100 +++++ pkg/services/umind_ingest_service.go | 267 +++++++++++++ pkg/services/umind_rag_service.go | 83 ++++ resources/views/ai_config.html | 6 +- resources/views/umind.html | 373 ++++++++++++++++++ .../api/umind_widget_controller.go | 175 ++++++++ rest/controllers/umind_admin_controller.go | 215 ++++++++++ rest/middlewares/auth_umind_widget.go | 66 ++++ rest/routes/hermes.go | 11 + rest/routes/publicas.go | 10 + rest/routes/user.go | 15 + 17 files changed, 1806 insertions(+), 4 deletions(-) create mode 100644 pkg/models/umind.go create mode 100644 pkg/services/umind_agent_service.go create mode 100644 pkg/services/umind_embeddings_service.go create mode 100644 pkg/services/umind_ingest_service.go create mode 100644 pkg/services/umind_rag_service.go create mode 100644 resources/views/umind.html create mode 100644 rest/controllers/api/umind_widget_controller.go create mode 100644 rest/controllers/umind_admin_controller.go create mode 100644 rest/middlewares/auth_umind_widget.go diff --git a/go.mod b/go.mod index b599d3e..450c9f9 100755 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe github.com/chromedp/chromedp v0.16.0 github.com/go-sql-driver/mysql v1.8.1 + github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 github.com/microsoft/go-mssqldb v1.7.2 @@ -55,6 +56,7 @@ require ( github.com/sirupsen/logrus v1.9.4 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e go.mongodb.org/mongo-driver v1.17.9 + golang.org/x/net v0.53.0 gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlserver v1.5.3 ) @@ -94,7 +96,6 @@ require ( github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gookit/filter v1.2.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -143,7 +144,6 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/term v0.43.0 // indirect diff --git a/main.go b/main.go index 93815d8..f13e5bc 100755 --- a/main.go +++ b/main.go @@ -146,6 +146,11 @@ func main() { &models.Arquitectura{}, // Vinculación de Telegram para staff interno &models.TelegramStaffToken{}, + // uMind: chat con IA embebible por tenant (widget web + RAG) + &models.UmindTenant{}, + &models.UmindDocumento{}, + &models.UmindChunk{}, + &models.UmindMensaje{}, } for _, m := range modelosBase { if err := app.Http.Database.DB.AutoMigrate(m); err != nil { @@ -180,6 +185,7 @@ func main() { migrations.SeedTareas() migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() + migrations.SeedUmind() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { log.Printf("[FIX] Error reparando estados de tareas: %v", err) } else if n > 0 { diff --git a/migrations/migrate.go b/migrations/migrate.go index 459e664..425cef7 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -117,6 +117,11 @@ func Migrate() { &models.Arquitectura{}, // Vinculación de Telegram para staff interno &models.TelegramStaffToken{}, + // uMind: chat con IA embebible por tenant (widget web + RAG) + &models.UmindTenant{}, + &models.UmindDocumento{}, + &models.UmindChunk{}, + &models.UmindMensaje{}, } for _, m := range modelosPrincipales { if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil { @@ -1267,3 +1272,40 @@ func SeedPagosExternos() { } log.Println("[SEED] Seed de Pagos externos completado.") } + +// SeedUmind registra el submódulo del panel de administración de uMind +// (tenants, base de conocimiento y conversaciones del widget embebible). +func SeedUmind() { + db := app.Http.Database.DB + var modulo models.Modules + if err := db.Where("title = ?", "Automatización IA").First(&modulo).Error; err != nil { + log.Println("[SEED] Módulo 'Automatización IA' no encontrado, se omite SeedUmind") + return + } + url := "/app/umind" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "uMind", + Description: "Chat con IA embebible por sitio, con base de conocimiento propia (RAG)", + Url: url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo 'uMind': %v", err) + return + } + log.Printf("[SEED] Submódulo 'uMind' creado") + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + var rol models.Roles + if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil { + log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) + } else { + asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub}) + log.Printf("[SEED] Submódulo 'uMind' asignado al rol 'Administrador'") + } + log.Println("[SEED] Seed de uMind completado.") +} diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index c3d94e1..3f19085 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -194,3 +194,26 @@ func GetWhisperConfig() (*AiConfig, error) { } return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config") } + +// GetUmindEmbeddingsConfig retorna la config activa etiquetada con el módulo +// "umind_embeddings" (usada para generar los vectores de la base de +// conocimiento de todos los tenants de uMind). Es global, no por tenant: los +// embeddings de un tenant solo son comparables entre sí si se generaron con +// el mismo modelo, así que cambiar de config invalida los chunks existentes +// (habría que reingestar). Sin fallback, igual que GetWhisperConfig — Claude +// no ofrece embeddings, así que aquí sí importa exigir una config explícita +// en vez de caer a cualquier config activa. +func GetUmindEmbeddingsConfig() (*AiConfig, error) { + var items []AiConfig + if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil { + return nil, fmt.Errorf("error leyendo ai_configs: %w", err) + } + for i := range items { + for _, m := range SplitModulos(items[i].Modulo) { + if m == "umind_embeddings" { + return &items[i], nil + } + } + } + return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'umind_embeddings' en /app/ai-config (necesaria para generar embeddings, ej. un proveedor OpenAI)") +} diff --git a/pkg/models/umind.go b/pkg/models/umind.go new file mode 100644 index 0000000..fe6492d --- /dev/null +++ b/pkg/models/umind.go @@ -0,0 +1,271 @@ +package models + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// UmindTenant representa un sitio/cliente que tiene el widget de uMind +// instalado. SiteKey es pública (va en el diff --git a/rest/controllers/api/umind_widget_controller.go b/rest/controllers/api/umind_widget_controller.go new file mode 100644 index 0000000..55ec14b --- /dev/null +++ b/rest/controllers/api/umind_widget_controller.go @@ -0,0 +1,175 @@ +package controllers + +import ( + "crypto/rand" + "encoding/hex" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +func tenantDeContexto(c *fiber.Ctx) (*models.UmindTenant, error) { + tenant, ok := c.Locals("umind_tenant").(*models.UmindTenant) + if !ok || tenant == nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado") + } + return tenant, nil +} + +func nuevaSessionID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// UmindWidgetInit devuelve una sesión nueva y el mensaje de bienvenida del +// tenant, sin gastar una llamada al LLM solo para saludar. +// Ruta: GET /widget/:site_key/init +func UmindWidgetInit(c *fiber.Ctx) error { + tenant, err := tenantDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + bienvenida := strings.TrimSpace(tenant.MensajeBienvenida) + if bienvenida == "" { + bienvenida = "¡Hola! ¿En qué puedo ayudarte?" + } + return c.JSON(fiber.Map{ + "session_id": nuevaSessionID(), + "nombre": tenant.Nombre, + "mensaje_bienvenida": bienvenida, + }) +} + +// UmindWidgetMensaje procesa un mensaje del visitante y devuelve la respuesta del agente. +// Ruta: POST /widget/:site_key/mensaje +func UmindWidgetMensaje(c *fiber.Ctx) error { + tenant, err := tenantDeContexto(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + + var req struct { + SessionID string `json:"session_id"` + Mensaje string `json:"mensaje"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "body inválido"}) + } + if strings.TrimSpace(req.Mensaje) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "mensaje requerido"}) + } + sessionID := strings.TrimSpace(req.SessionID) + if sessionID == "" { + sessionID = nuevaSessionID() + } + // Límite generoso pero real: evita que alguien mande un mensaje gigante al LLM. + if len(req.Mensaje) > 4000 { + req.Mensaje = req.Mensaje[:4000] + } + + respuesta, err := services.ProcessWidgetMessage(tenant, sessionID, strings.TrimSpace(req.Mensaje)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": true, "message": err.Error()}) + } + + return c.JSON(fiber.Map{ + "session_id": sessionID, + "respuesta": respuesta, + }) +} + +// UmindWidgetScript sirve el script embebible que renderiza la burbuja de +// chat. Se instala con: +// Ruta: GET /widget/umind.js +func UmindWidgetScript(c *fiber.Ctx) error { + c.Set("Content-Type", "application/javascript; charset=utf-8") + c.Set("Cache-Control", "public, max-age=300") + return c.SendString(umindWidgetJS) +} + +const umindWidgetJS = `(function () { + var script = document.currentScript; + var siteKey = script.getAttribute('data-site'); + if (!siteKey) { console.error('[uMind] falta data-site en el