Files
soft_usite/pkg/models/ai_config.go
T
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00

280 lines
10 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"` // 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)")
}