feat: log de auditoría técnica por agente

Nueva tabla umind_eventos_log + tab "Auditoría" en el panel: errores y
eventos que hasta ahora solo se podían ver pidiendo los logs del servidor
(fallos al llamar al AI, respuestas sin texto usable, tools/webhooks que
fallan, tokens de correo que no se pueden refrescar, firmas de WhatsApp
inválidas, errores procesando mensajes de Telegram/WhatsApp) quedan
registrados por agente, visibles directo en el panel con el JSON crudo
expandible para diagnosticar sin acceso al servidor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 09:58:59 -05:00
co-authored by Claude Sonnet 5
parent 5febbd24d5
commit 997e3cc790
13 changed files with 157 additions and 31 deletions
+51
View File
@@ -0,0 +1,51 @@
package models
import (
"log"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindEventoLog es el registro de auditoría técnica de un agente — errores
// y eventos que hoy solo quedaban en el log del servidor (invisibles desde
// el panel): fallos al llamar al AI, tools/webhooks que fallan, canales que
// no logran procesar un mensaje, etc. No es el historial de conversación
// (eso es UmindMensaje) ni el estado de ingesta (eso ya se ve por
// documento en UmindDocumento.Estado/Error) — es específicamente lo que
// antes solo se podía ver pidiendo los logs del servidor.
type UmindEventoLog struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Nivel string `json:"nivel" gorm:"column:nivel;size:10"` // error | warn
Origen string `json:"origen" gorm:"column:origen;size:40"` // ai | tool | email | canal_telegram | canal_whatsapp
Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"`
Detalle string `json:"detalle" gorm:"column:detalle;type:text"` // JSON crudo opcional, para diagnosticar sin pedir logs
}
func (UmindEventoLog) TableName() string { return "umind_eventos_log" }
// RegistrarEventoUmind guarda un evento de auditoría. No devuelve error a
// propósito — es un side-channel de diagnóstico, nunca debe interrumpir el
// flujo principal (responderle al visitante) si la escritura falla.
func RegistrarEventoUmind(agenteID uint, nivel, origen, mensaje, detalle string) {
e := &UmindEventoLog{AgenteID: agenteID, Nivel: nivel, Origen: origen, Mensaje: mensaje, Detalle: detalle}
if err := app.Http.Database.DB.Create(e).Error; err != nil {
log.Printf("[UMIND] No se pudo guardar el evento de auditoría (agente %d): %v", agenteID, err)
}
}
// GetUmindEventosByAgente retorna los últimos eventos del agente, más
// reciente primero — acotado para no crecer sin límite en la respuesta.
func GetUmindEventosByAgente(agenteID uint, limit int) ([]UmindEventoLog, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
var items []UmindEventoLog
err := app.Http.Database.DB.
Where("agente_id = ?", agenteID).
Order("id DESC").
Limit(limit).
Find(&items).Error
return items, err
}
+10
View File
@@ -59,12 +59,14 @@ func umindTools(agenteID uint) []agentTool {
herramientas, err := models.GetUmindHerramientasActivas(agenteID)
if err != nil {
log.Printf("[UMIND] Error leyendo tools custom del agente %d: %v", agenteID, err)
models.RegistrarEventoUmind(agenteID, "error", "tool", "Error leyendo tools custom", err.Error())
return tools
}
for _, h := range herramientas {
params, err := models.ParametrosFromJSON(h.ParametrosJSON)
if err != nil {
log.Printf("[UMIND] Tool %q del agente %d tiene parametros_json inválido, se omite: %v", h.Nombre, agenteID, err)
models.RegistrarEventoUmind(agenteID, "warn", "tool", fmt.Sprintf("Tool %q tiene parametros_json inválido, se omitió", h.Nombre), err.Error())
continue
}
props := map[string]agentToolParam{}
@@ -168,12 +170,14 @@ func executeUmindTool(agenteID uint, name string, args map[string]interface{}) s
authValor, err = DescifrarSecretoUmind(herramienta.AuthHeaderValorEnc)
if err != nil {
log.Printf("[UMIND] Error descifrando credencial de tool %q: %v", name, err)
models.RegistrarEventoUmind(agenteID, "error", "tool", fmt.Sprintf("Error descifrando credencial de la tool %q", name), err.Error())
return `{"error": "la tool no está configurada correctamente"}`
}
}
resultado, err := LlamarHerramientaWebhook(herramienta.URL, herramienta.AuthHeaderNombre, authValor, args)
if err != nil {
log.Printf("[UMIND] Error llamando tool %q del agente %d: %v", name, agenteID, err)
models.RegistrarEventoUmind(agenteID, "error", "tool", fmt.Sprintf("Error llamando la tool %q", name), err.Error())
return fmt.Sprintf(`{"error": %q}`, "no se pudo completar la acción, intenta de nuevo")
}
return resultado
@@ -189,6 +193,7 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
}
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error refrescando el token de la cuenta de correo conectada", err.Error())
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
@@ -208,6 +213,7 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
}
if envErr != nil {
log.Printf("[UMIND] Error enviando correo (agente %d): %v", agenteID, envErr)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error enviando correo", envErr.Error())
return `{"error": "no se pudo enviar el correo"}`
}
return `{"ok": true}`
@@ -223,6 +229,7 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
}
if lecErr != nil {
log.Printf("[UMIND] Error leyendo bandeja (agente %d): %v", agenteID, lecErr)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error leyendo la bandeja de correo", lecErr.Error())
return `{"error": "no se pudo leer la bandeja"}`
}
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
@@ -279,6 +286,7 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
aiMsg, err := callAI(&ai, messages, tools)
if err != nil {
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
return "", fmt.Errorf("error al contactar el sistema de IA")
}
@@ -293,6 +301,7 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
// "dame más detalle" sin ninguna pista de qué pasó. Con .Raw (ver
// agentMessage.UnmarshalJSON) queda el JSON crudo para diagnosticar.
log.Printf("[UMIND] Respuesta del AI sin texto usable (agente %d) round %d: %s", agente.ID, round, string(aiMsg.Raw))
models.RegistrarEventoUmind(agente.ID, "warn", "ai", fmt.Sprintf("El AI respondió sin texto usable (ronda %d)", round), string(aiMsg.Raw))
}
finalResponse = content
if strings.TrimSpace(content) != "" {
@@ -317,6 +326,7 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
if finalResponse == "" {
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin una respuesta final", agente.ID)
models.RegistrarEventoUmind(agente.ID, "warn", "ai", "Se agotaron las rondas de tool-calling sin una respuesta final", "")
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
}
return finalResponse, nil