Files
soft_usite/pkg/services/umind_vigilancia_service.go
T
Lizandro GuarnizoandClaude Opus 5 7ddc3f5286 feat(umind): vigilancia de APIs — avisa cuando pasa algo, sin quemar plata
"Avisame cuando esta API devuelva stock en cero." La vigilancia apunta a una
HERRAMIENTA ya configurada del agente y nunca a una URL libre: así hereda
entero el blindaje de LlamarHerramientaWebhook —DNS resuelto en el connect,
IPs internas rechazadas, sin redirects, respuesta acotada— sin reimplementar
una línea de eso. Hay un test que falla si alguien le agrega un campo url.

El ahorro que hace viable la feature: se hashea la respuesta y, si no cambió,
no hay nada que evaluar y no se gasta un token. Preguntarle a la IA en cada
chequeo serían 1440 consultas diarias por cliente para responder algo que ya
sabíamos. El test verifica que el corte por hash esté ANTES de la evaluación.

Y se avisa al ENTRAR en condición, no en cada chequeo que la siga cumpliendo:
una alerta que llega cada media hora deja de leerse a la segunda. Es la lección
que el monitor de sitios ya había aprendido con las transiciones de estado.

Solo un SI explícito dispara el aviso. Un modelo que devuelve una explicación,
un error o cualquier otra cosa se toma como que no: un aviso que no llega
molesta menos que uno que despierta a alguien a las 3 AM sin motivo.

Si el envío del aviso falla, no se marca como avisado — se reintenta al
siguiente ciclo. Y tras diez fallos seguidos la vigilancia se apaga sola: una
API que dejó de existir no puede consultarse para siempre ni seguir gastando el
plan de quien la configuró.

Máximo 5 por agente, mínimo 5 minutos de intervalo. Solo canal interno: cada
vigilancia es una llamada saliente recurrente y consultas a la IA, o sea plan
del dueño gastado por quien la pida.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:43:43 -05:00

162 lines
5.5 KiB
Go

package services
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// La vigilancia de APIs: "avisame cuando esta API devuelva stock en cero".
var vigilanciasEnCurso sync.Mutex
// vigilanciaFallosMax es cuántos errores seguidos aguanta antes de apagarse.
// Una API que dejó de existir no puede consultarse para siempre.
const vigilanciaFallosMax = 10
// RevisarVigilancias corre cada minuto y atiende solo las que ya les toca.
func RevisarVigilancias() {
if !vigilanciasEnCurso.TryLock() {
return
}
defer vigilanciasEnCurso.Unlock()
vigilancias, err := models.GetUmindVigilanciasPendientes()
if err != nil {
log.Printf("[UMIND_VIGILA] no se pudieron listar: %v", err)
return
}
if len(vigilancias) == 0 {
return
}
const trabajadores = 4
cola := make(chan models.UmindVigilancia)
var wg sync.WaitGroup
for w := 0; w < trabajadores; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range cola {
revisarUnaVigilancia(v)
}
}()
}
for _, v := range vigilancias {
cola <- v
}
close(cola)
wg.Wait()
}
func revisarUnaVigilancia(v models.UmindVigilancia) {
herramienta, err := models.GetUmindHerramientaByNombre(v.AgenteID, v.Herramienta)
if err != nil {
models.DesactivarVigilancia(v.ID, "la herramienta que vigilaba ya no existe")
models.RegistrarEventoUmind(v.AgenteID, "warn", "vigilancia",
"Se apagó la vigilancia "+v.Nombre+": la herramienta que usaba ya no existe", "")
return
}
authValor := ""
if herramienta.AuthHeaderValorEnc != "" {
if authValor, err = DescifrarSecretoUmind(herramienta.AuthHeaderValorEnc); err != nil {
models.ActualizarVigilancia(v.ID, map[string]interface{}{"ultimo_error": "credencial ilegible"})
return
}
}
respuesta, err := LlamarHerramientaWebhook(herramienta.URL, herramienta.AuthHeaderNombre, authValor, map[string]interface{}{})
if err != nil {
fallos := v.FallosNoOk + 1
if fallos >= vigilanciaFallosMax {
models.DesactivarVigilancia(v.ID, err.Error())
models.RegistrarEventoUmind(v.AgenteID, "warn", "vigilancia",
"Se apagó la vigilancia "+v.Nombre+" tras fallar muchas veces seguidas", err.Error())
return
}
models.ActualizarVigilancia(v.ID, map[string]interface{}{"ultimo_error": err.Error(), "fallos_no_ok": fallos})
return
}
// Acá está el ahorro: si la respuesta es idéntica a la anterior, no hay
// nada que evaluar. Preguntarle a la IA en cada chequeo costaría una
// fortuna por una respuesta que ya sabemos.
suma := sha256.Sum256([]byte(respuesta))
hash := hex.EncodeToString(suma[:])
if hash == v.HashUltimo {
models.ActualizarVigilancia(v.ID, map[string]interface{}{"ultimo_error": "", "fallos_no_ok": 0})
return
}
cumple, err := EvaluarCondicionVigilancia(v.Condicion, respuesta)
if err != nil {
models.ActualizarVigilancia(v.ID, map[string]interface{}{
"hash_ultimo": hash, "ultimo_error": "no se pudo evaluar la condición: " + err.Error(),
})
return
}
models.RegistrarUsoUmind(v.AgenteID, models.UsoTipoIA, 400, "tokens")
updates := map[string]interface{}{"hash_ultimo": hash, "ultimo_error": "", "fallos_no_ok": 0, "en_condicion": cumple}
// Se avisa al ENTRAR en condición. Si ya estaba y sigue, no se repite:
// una alerta que llega cada media hora deja de leerse a la segunda.
if cumple && !v.EnCondicion {
if err := avisarVigilancia(v, respuesta); err != nil {
log.Printf("[UMIND_VIGILA] no se pudo avisar de %q: %v", v.Nombre, err)
models.RegistrarEventoUmind(v.AgenteID, "error", "vigilancia", "No se pudo avisar de la vigilancia "+v.Nombre, err.Error())
// Sin marcar en_condicion: se reintenta el aviso al próximo ciclo.
delete(updates, "en_condicion")
}
}
models.ActualizarVigilancia(v.ID, updates)
}
// EvaluarCondicionVigilancia le pregunta a la IA si lo que devolvió la API
// cumple la condición. Se le exige SI o NO para que la respuesta sea usable:
// un modelo que explica su razonamiento acá no sirve de nada.
func EvaluarCondicionVigilancia(condicion, respuesta string) (bool, error) {
if len(respuesta) > 6000 {
respuesta = respuesta[:6000]
}
sistema := "Sos un evaluador. Te dan la respuesta de una API y una condición. " +
"Respondé EXACTAMENTE una palabra: SI si la condición se cumple, NO si no se cumple. " +
"Ante la duda respondé NO."
usuario := fmt.Sprintf("Condición: %s\n\nRespuesta de la API:\n%s", condicion, respuesta)
out, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
return false, err
}
return interpretarVeredicto(out), nil
}
// interpretarVeredicto acepta solo un SI explícito. Cualquier otra cosa —una
// explicación, un error, un modelo confundido— se toma como que no: es
// preferible un aviso que no llega a uno que llega sin motivo a las 3 AM.
func interpretarVeredicto(out string) bool {
limpio := strings.ToUpper(strings.TrimSpace(out))
limpio = strings.Trim(limpio, ".!\"' \n")
return limpio == "SI" || limpio == "SÍ" ||
strings.HasPrefix(limpio, "SI ") || strings.HasPrefix(limpio, "SI,") ||
strings.HasPrefix(limpio, "SÍ ") || strings.HasPrefix(limpio, "SÍ,")
}
// avisarVigilancia reusa la entrega de los recordatorios: mismo destino, mismo
// camino, una sola implementación que mantener.
func avisarVigilancia(v models.UmindVigilancia, respuesta string) error {
detalle := v.Condicion
if len(respuesta) < 800 {
detalle += "\n\nLo que devolvió la API:\n" + respuesta
}
return entregarAviso(models.UmindAviso{
AgenteID: v.AgenteID,
Titulo: "Se cumplió lo que estabas vigilando: " + v.Nombre,
Detalle: detalle,
Destino: v.Destino,
})
}