es_agente_bot no se podía marcar desde ninguna pantalla —el formulario nunca mandaba el campo— y el update lo escribía igual con el valor cero. O sea que guardar cualquier config desde /app/ai-config apagaba el cerebro del bot de Telegram y del chat del panel, y no había forma de volver a prenderlo salvo tocando la base. - El update solo escribe los campos que vinieron en el body. - El formulario tiene la casilla y el selector de bot de Telegram. - Marcar una desmarca la anterior: GetAgenteBotAiConfig hace First(), así que con dos marcadas ganaba la que estuviera primero en la tabla. Y el otro comportamiento raro: cuando ningún módulo coincidía, se usaba "cualquier config activa". Eso podía elegir la de embeddings o la de Whisper, que no conversan — el error que llegaba era del proveedor y no se parecía en nada a la causa. Ahora esas quedan excluidas del comodín y, si no queda ninguna usable, el error dice qué módulo asignar y dónde. De paso: la etiqueta "IA / vCard" mentía (ese módulo alimenta además soporte, el chat del panel y las plantillas), el comentario de is_active decía "solo uno activo a la vez" cuando hace falta uno por módulo, y GetActiveAiConfig no la usaba nadie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
12 KiB
Go
313 lines
12 KiB
Go
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"` // varias pueden estar activas: una por módulo
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
|
|
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
|
|
// gana depende del orden de la tabla, que no es una forma de elegir nada.
|
|
func QuitarAgenteBotSalvo(id uint) {
|
|
app.Http.Database.DB.Model(&AiConfig{}).
|
|
Where("id <> ? AND es_agente_bot = ?", id, true).
|
|
Update("es_agente_bot", false)
|
|
}
|
|
|
|
// 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, pero nunca una que esté
|
|
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
|
|
// vectores y la de Whisper transcribe audio. Caer ahí daba errores del
|
|
// proveedor imposibles de relacionar con esta elección.
|
|
for i := range items {
|
|
if esConfigDeUsoEspecial(items[i].Modulo) {
|
|
continue
|
|
}
|
|
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
|
|
return &items[i], nil
|
|
}
|
|
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
|
|
}
|
|
|
|
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
|
|
// no sirven como comodín para otra cosa.
|
|
func esConfigDeUsoEspecial(modulo string) bool {
|
|
for _, m := range SplitModulos(modulo) {
|
|
if m == "whisper" || m == "umind_embeddings" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
|
|
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
|
|
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
|
|
// tiene fallback a la global y a cualquier activa.
|
|
func HayAiConfigParaModulo(modulo string) bool {
|
|
var items []AiConfig
|
|
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
|
|
return false
|
|
}
|
|
for i := range items {
|
|
for _, m := range SplitModulos(items[i].Modulo) {
|
|
if m == modulo {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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)")
|
|
}
|