diff --git a/pkg/services/umind_agent_fallback_test.go b/pkg/services/umind_agent_fallback_test.go new file mode 100644 index 0000000..5732fd0 --- /dev/null +++ b/pkg/services/umind_agent_fallback_test.go @@ -0,0 +1,61 @@ +package services + +import ( + "strings" + "testing" +) + +// Cuando se agotan las rondas de herramientas, el visitante recibía un mensaje +// genérico aunque el modelo ya le hubiera escrito una respuesta completa: el +// texto que viene junto con una llamada a herramienta se descartaba. +// +// Se prueba la lógica de selección, que es la que decide qué ve la persona. +func TestSePrefiereElTextoDelModeloAlMensajeGenerico(t *testing.T) { + const generico = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas." + + elegir := func(final, parcial string) string { + if final != "" { + return final + } + if strings.TrimSpace(parcial) != "" { + return parcial + } + return generico + } + + casos := []struct { + nombre string + final string + parcial string + esperado string + }{ + {"respuesta normal", "Los 4 pilares son…", "", "Los 4 pilares son…"}, + {"se agotaron las rondas pero el modelo había escrito", "", "Los 4 pilares son…", "Los 4 pilares son…"}, + {"el modelo nunca escribió nada", "", "", generico}, + {"texto parcial en blanco no cuenta", "", " \n ", generico}, + } + + for _, c := range casos { + if got := elegir(c.final, c.parcial); got != c.esperado { + t.Errorf("%s: eligió %q, esperaba %q", c.nombre, got, c.esperado) + } + } +} + +// El resultado de una herramienta que falló se reconoce por su forma. Si esa +// detección se rompe, el evento de diagnóstico deja de registrarse y "se +// agotaron las rondas" vuelve a no decir dónde mirar. +func TestSeDetectaElErrorDeUnaHerramienta(t *testing.T) { + casos := map[string]bool{ + `{"error": "consulta requerida"}`: true, + ` {"error": "el servicio de embeddings no está configurado"}`: true, + `{"resultados": ["texto"]}`: false, + `{"resultados": [], "nota": "No se encontró información relacionada"}`: false, + } + for entrada, esError := range casos { + got := strings.HasPrefix(strings.TrimSpace(entrada), `{"error"`) + if got != esError { + t.Errorf("%q: detectado como error = %v, esperaba %v", entrada, got, esError) + } + } +} diff --git a/pkg/services/umind_agent_service.go b/pkg/services/umind_agent_service.go index 876ef98..9211bb3 100644 --- a/pkg/services/umind_agent_service.go +++ b/pkg/services/umind_agent_service.go @@ -283,6 +283,13 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string _ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText) var finalResponse string + // Lo último que el modelo llegó a escribir, aunque haya venido acompañado + // de una llamada a herramienta. + var ultimoTextoParcial string + // Para el evento de diagnóstico: sin saber qué herramienta pidió en cada + // ronda, "se agotaron las rondas" no dice dónde mirar. + var herramientasPedidas []string + for round := 0; round < 3; round++ { aiMsg, tokens, err := callAI(&ai, messages, tools) if err != nil { @@ -314,11 +321,28 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string break } + // Un modelo puede mandar texto Y pedir herramientas en el mismo turno. + // Ese texto se descartaba: si después se agotaban las rondas, el + // visitante recibía el mensaje genérico aunque el agente ya le hubiera + // escrito una respuesta buena. + if s, ok := aiMsg.Content.(string); ok && strings.TrimSpace(s) != "" { + ultimoTextoParcial = s + } + messages = append(messages, *aiMsg) for _, tc := range aiMsg.ToolCalls { var toolArgs map[string]interface{} _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs) toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs) + herramientasPedidas = append(herramientasPedidas, tc.Function.Name) + // Una herramienta que devuelve error y el modelo que la reintenta es + // la forma más común de agotar las rondas. Queda registrado para + // poder verlo en Auditoría en vez de deducirlo. + if strings.HasPrefix(strings.TrimSpace(toolResult), `{"error"`) { + models.RegistrarEventoUmind(agente.ID, "warn", "tool", + fmt.Sprintf("La herramienta %q devolvió error en la ronda %d", tc.Function.Name, round), + toolResult) + } messages = append(messages, agentMessage{ Role: "tool", ToolCallID: tc.ID, @@ -329,9 +353,19 @@ 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." + log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin respuesta final (herramientas: %v)", + agente.ID, herramientasPedidas) + models.RegistrarEventoUmind(agente.ID, "warn", "ai", + "Se agotaron las 3 rondas de herramientas sin una respuesta final", + fmt.Sprintf("Herramientas pedidas en orden: %s", strings.Join(herramientasPedidas, " → "))) + + // Antes de dar el mensaje genérico, usar lo que el modelo ya escribió. + if strings.TrimSpace(ultimoTextoParcial) != "" { + finalResponse = ultimoTextoParcial + _ = models.SaveUmindMensaje(agente.ID, sessionID, "assistant", finalResponse) + } else { + finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas." + } } return finalResponse, nil }