Files
soft_usite/pkg/models/ai_config.go
T
Lizandro GD 5ee5880dc2 Agrega uMind: chat con IA embebible por tenant (F1 — widget web + RAG)
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.
2026-08-07 16:18:16 +00:00

220 lines
8.2 KiB
Go

package models
import (
"fmt"
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// AiConfig almacena las configuraciones de proveedores de IA (Qwen, OpenAI, etc.)
// que son usadas por el Landing Generator y otros módulos.
type AiConfig struct {
gorm.Model
Nombre string `gorm:"size:100;not null" json:"nombre"` // Alias amigable, ej: "Qwen 2.5 Producción"
Provider string `gorm:"size:50;not null" json:"provider"` // qwen | openai | anthropic | etc
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez
Notes string `gorm:"type:text" json:"notes"`
// Modulo indica a qué servicio pertenece esta config.
// "" = global (disponible para todos como fallback)
// "landing" = exclusivo para Landing Generator
// "query_runner" = exclusivo para Query Runner SQL
Modulo string `gorm:"size:50;default:''" json:"modulo"`
// Agente Telegram: si EsAgenteBot=true, esta config es el cerebro del bot administrador.
// Solo debe haber una config activa como agente a la vez.
EsAgenteBot bool `gorm:"default:false" json:"es_agente_bot"`
TelegramConfigID *uint `gorm:"index" json:"telegram_config_id"`
}
func (AiConfig) TableName() string { return "ai_configs" }
func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) {
var items []AiConfig
var total int64
db := app.Http.Database.DB.Model(&AiConfig{})
if search != "" {
db = db.Where("nombre LIKE ? OR provider LIKE ?", "%"+search+"%", "%"+search+"%")
}
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
}
func CreateAiConfig(item *AiConfig) error {
return app.Http.Database.DB.Create(item).Error
}
func UpdateAiConfig(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&AiConfig{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteAiConfig(id uint) error {
return app.Http.Database.DB.Delete(&AiConfig{}, id).Error
}
func GetAiConfigByID(id uint, out *AiConfig) error {
return app.Http.Database.DB.First(out, id).Error
}
// GetActiveAiConfig retorna la primera configuración activa del provider indicado.
// Si provider está vacío, retorna cualquier config activa.
func GetActiveAiConfig(provider string) (*AiConfig, error) {
var item AiConfig
db := app.Http.Database.DB.Where("is_active = ?", true)
if provider != "" {
db = db.Where("provider = ?", provider)
}
if err := db.First(&item).Error; err != nil {
log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err)
return nil, err
}
return &item, nil
}
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
func SplitModulos(modulo string) []string {
var out []string
for _, s := range strings.Split(modulo, ",") {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// JoinModulos normaliza y une un slice de módulos en comma-separated.
func JoinModulos(modules []string) string {
var clean []string
for _, s := range modules {
if s = strings.TrimSpace(s); s != "" {
clean = append(clean, s)
}
}
return strings.Join(clean, ",")
}
func GetAiConfigSelect() ([]AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider").Order("nombre ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
}
if ai.TelegramConfigID == nil {
return &ai, nil, fmt.Errorf("el agente no tiene bot de Telegram asignado")
}
tg, err := GetTelegramConfigByID(*ai.TelegramConfigID)
if err != nil {
return &ai, nil, fmt.Errorf("bot de Telegram no encontrado: %w", err)
}
return &ai, tg, nil
}
// GetAgenteBotAiConfig retorna solo la config de IA marcada como agente (el mismo
// "cerebro" que usa el bot de Telegram), sin exigir que tenga un bot de Telegram
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err)
}
return &ai, nil
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad:
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
// 2. Config activa con modulo == "" (global, fallback)
// 3. Cualquier config activa (último recurso)
func GetAiConfigForService(service string) (*AiConfig, error) {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
if len(items) == 0 {
return nil, fmt.Errorf("no hay configs de IA activas")
}
// 1. Config específica para el servicio (puede tener varios módulos)
if service != "" {
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == service {
return &items[i], nil
}
}
}
}
// 2. Config global (modulo vacío = sin restricción de servicio)
for i := range items {
if strings.TrimSpace(items[i].Modulo) == "" {
return &items[i], nil
}
}
// 3. Cualquier config activa como último recurso
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
return &items[0], nil
}
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
// el módulo "whisper" (transcripción de audio). A diferencia de
// GetAiConfigForService, NO cae a una config global: si el admin no configuró
// una explícitamente para whisper, es mejor avisar con claridad que intentar
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
func GetWhisperConfig() (*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 == "whisper" {
return &items[i], nil
}
}
}
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)")
}