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:
co-authored by
Claude Sonnet 5
parent
5febbd24d5
commit
997e3cc790
@@ -155,6 +155,7 @@ func main() {
|
||||
&models.UmindHerramienta{},
|
||||
&models.UmindCanal{},
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ func Migrate() {
|
||||
&models.UmindHerramienta{},
|
||||
&models.UmindCanal{},
|
||||
&models.UmindConexion{},
|
||||
&models.UmindEventoLog{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
}
|
||||
|
||||
@@ -273,11 +273,33 @@ const tabs = [
|
||||
['conexiones', 'Conexiones'],
|
||||
['chat', 'Chat de prueba'],
|
||||
['conversaciones', 'Conversaciones'],
|
||||
['auditoria', 'Auditoría'],
|
||||
]
|
||||
|
||||
// ─── Auditoría ────────────────────────────────────────────────────────────────
|
||||
const eventos = ref([])
|
||||
|
||||
async function cargarEventos() {
|
||||
const r = await api.get(`/app/umind/eventos?agente_id=${props.agenteId}`)
|
||||
eventos.value = r.items || []
|
||||
}
|
||||
|
||||
const nivelColor = computed(() => (nivel) => ({
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
|
||||
}[nivel] || 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'))
|
||||
|
||||
function formatearFecha(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones()])
|
||||
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones(), cargarEventos()])
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
@@ -583,7 +605,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Conversaciones -->
|
||||
<div v-else class="grid grid-cols-3 gap-4">
|
||||
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[28rem] overflow-y-auto">
|
||||
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500 dark:text-gray-400">Sin conversaciones.</div>
|
||||
<button
|
||||
@@ -609,5 +631,24 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auditoría -->
|
||||
<div v-else>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Errores y eventos técnicos de este agente — fallos al llamar al AI, a tools, a correo o a los canales. Últimos 100.
|
||||
</p>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[32rem] overflow-y-auto">
|
||||
<div v-if="eventos.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin eventos registrados — buena señal.</div>
|
||||
<details v-for="e in eventos" :key="e.ID" class="p-3">
|
||||
<summary class="cursor-pointer flex items-center gap-2 text-sm">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs shrink-0">{{ e.origen }}</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 truncate">{{ e.mensaje }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
|
||||
</summary>
|
||||
<pre v-if="e.detalle" class="mt-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uMind — Orquestador</title>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-CdLtaNTu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-BOzE-OA0.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-CUIRjjam.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CntnaNBS.css">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -46,6 +46,7 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
|
||||
|
||||
if err := services.ProcesarMensajeTelegramUmind(canal, update.Message.Chat.ID, texto); err != nil {
|
||||
log.Printf("[UMIND_TELEGRAM] canal %d: %v", canal.ID, err)
|
||||
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_telegram", "Error procesando un mensaje de Telegram", err.Error())
|
||||
}
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
@@ -112,11 +113,13 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
|
||||
credenciales, err := services.DescifrarCredencialesCanal(canal.CredencialesEnc)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_WHATSAPP] canal %d: credenciales corruptas: %v", canal.ID, err)
|
||||
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Credenciales del canal corruptas", err.Error())
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
if !services.ValidarFirmaWhatsApp(credenciales["app_secret"], c.Body(), c.Get("X-Hub-Signature-256")) {
|
||||
log.Printf("[UMIND_WHATSAPP] canal %d: firma inválida", canal.ID)
|
||||
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Firma X-Hub-Signature-256 inválida en un webhook entrante", "")
|
||||
return c.SendStatus(fiber.StatusUnauthorized)
|
||||
}
|
||||
|
||||
@@ -132,6 +135,7 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
|
||||
}
|
||||
if err := services.ProcesarMensajeWhatsAppUmind(canal, msg.From, strings.TrimSpace(msg.Text.Body)); err != nil {
|
||||
log.Printf("[UMIND_WHATSAPP] canal %d: %v", canal.ID, err)
|
||||
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Error procesando un mensaje de WhatsApp", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,3 +582,20 @@ func UmindChatPruebaHandler(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
|
||||
}
|
||||
|
||||
// ─── Auditoría ──────────────────────────────────────────────────────────────
|
||||
|
||||
// GetUmindEventosHandler expone el log de auditoría técnica de un agente —
|
||||
// errores/eventos que antes solo se podían ver pidiendo los logs del
|
||||
// servidor (fallos de AI, tools, correo, canales).
|
||||
func GetUmindEventosHandler(c *fiber.Ctx) error {
|
||||
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
||||
}
|
||||
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": items})
|
||||
}
|
||||
|
||||
@@ -382,6 +382,7 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/umind/conexiones/conectar", middlewares.SoloAdmin, controllers.UmindConectarHandler)
|
||||
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
|
||||
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
|
||||
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
|
||||
|
||||
// ─── uMind Orquestador (SPA Vue) ────────────────────────────────────────────
|
||||
// Estáticos reales (JS/CSS del build) ya los sirve el Static("/") general
|
||||
|
||||
Reference in New Issue
Block a user