package models import ( "fmt" "log" "strings" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/utils" "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"` // TenantID acota la config a un tenant de uMind: null = config global del // staff (el comportamiento histórico). Sin esto, el selector de IA le // mostraría a cada cliente las claves de todos los demás. TenantID *uint `gorm:"index" json:"tenant_id"` } func (AiConfig) TableName() string { return "ai_configs" } // ClaveEnClaro devuelve la API key lista para usar. Las filas guardadas antes // de que se cifrara este campo están en texto plano y se devuelven tal cual; // al volver a guardarlas quedan cifradas, así que el parque se migra solo sin // script ni downtime. // // utils.Decrypt hace panic con entrada que no sea un ciphertext válido (no // devuelve error), de ahí el recover: es el mecanismo de detección de "esto // todavía está en texto plano". func (c *AiConfig) ClaveEnClaro() string { if c.ApiKey == "" || app.Http.Server.Key == "" { return c.ApiKey } return descifrarOTalCual(c.ApiKey) } func descifrarOTalCual(valor string) (out string) { defer func() { if recover() != nil { out = valor } }() claro := utils.Decrypt(valor, app.Http.Server.Key) if claro == "" { return valor } return claro } // CifrarClaveAi cifra una API key para guardarla. Si no hay APP_KEY // configurada devuelve el valor tal cual — preferible a romper el guardado en // un entorno sin la clave, y ClaveEnClaro lo lee igual. func CifrarClaveAi(clave string) string { if clave == "" || app.Http.Server.Key == "" { return clave } return utils.Encrypt(clave, app.Http.Server.Key) } 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 } // GetAiConfigSelectPorTenants acota el selector a las configs propias de esos // tenants más las globales del staff (tenant_id IS NULL), que son las que se // ofrecen a todos. Sin este filtro el cliente vería las claves de los demás. func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) { var items []AiConfig db := app.Http.Database.DB.Model(&AiConfig{}).Select("id, nombre, provider, tenant_id") if len(tenantIDs) == 0 { db = db.Where("tenant_id IS NULL") } else { db = db.Where("tenant_id IS NULL OR tenant_id IN ?", tenantIDs) } if err := db.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)") }