fix(ai-config): editar cualquier config dejaba al agente sin cerebro

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>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 21:16:15 -05:00
co-authored by Claude Opus 5
parent 6a3a8c9248
commit f5ffed7a13
4 changed files with 116 additions and 33 deletions
+33 -19
View File
@@ -19,7 +19,7 @@ type AiConfig struct {
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
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)
@@ -108,21 +108,6 @@ 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 {
@@ -171,6 +156,15 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
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
@@ -230,9 +224,29 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
}
}
// 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
// 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.
+23
View File
@@ -0,0 +1,23 @@
package models
import "testing"
// El fallback de GetAiConfigForService puede terminar usando una config que no
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
// no se parece en nada a la causa real.
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
casos := map[string]bool{
"whisper": true,
"umind_embeddings": true,
"landing,umind_embeddings": true,
"": false,
"ia": false,
"landing,query_runner": false,
}
for modulo, want := range casos {
if got := esConfigDeUsoEspecial(modulo); got != want {
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
}
}
}