feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos

Dos de las cuatro mejoras de la auditoría del agente.

1. Las herramientas aceptan cliente_nombre además de cliente_id.

   De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el
   modelo tenía que encadenar: listar_clientes → leer la respuesta →
   encontrar la fila → extraer el número → recién ahí llamar a la
   herramienta. Cuatro pasos, y basta que falle uno para que no se guarde
   nada — es la explicación más probable de la factura que nunca se creó,
   porque ese cliente podía no venir en la primera página del listado.

   Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda
   completa y no paginada. Si hay varios candidatos devuelve la lista para
   que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura
   a la empresa equivocada es peor que preguntar. Una coincidencia exacta
   gana sobre las parciales, así "Metropolitana" no queda ambiguo solo
   porque existe "Metropolitana Norte".

2. Contadores de uso y fallo por herramienta, expuestos como
   estado_herramientas.

   Cada problema se venía diagnosticando de a un caso, reconstruyendo qué
   pasó después de que el usuario avisara. Ahora se puede preguntar
   directamente qué viene fallando y ver si es una herramienta puntual o el
   modelo eligiendo mal. En memoria a propósito: alcanza para responder eso
   y no agrega una tabla.

Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales
por dominio a las descripciones de las herramientas, y comparar modelos con
un mismo caso. La primera conviene hacerla con el sistema estable, porque
toca cómo decide el modelo en todos los flujos a la vez.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 21:24:06 -05:00
co-authored by Claude Sonnet 5
parent d0c9ed5656
commit e2440b04f1
3 changed files with 217 additions and 29 deletions
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Resolución de entidades por nombre para el agente de Telegram.
//
// La mayoría de las herramientas pedían un ID numérico, y el modelo tenía que
// conseguirlo antes: llamar a listar_*, leer la respuesta, encontrar la fila
// correcta y extraer el número. Cuatro pasos encadenados por cada acción, y
// basta que falle uno para que no se guarde nada — es lo que pasó con una
// factura cuyo cliente no salía en la primera página del listado.
//
// Aceptando el nombre y resolviéndolo acá, esa cadena desaparece: el modelo
// pasa lo que el usuario dijo y el servidor hace la búsqueda, que además es
// exacta y no depende de que el listado estuviera paginado.
// ResolverClienteID devuelve el ID a partir de un id explícito o de un nombre.
//
// Si el nombre coincide con varios clientes devuelve un error que los enumera:
// es mejor que el modelo pregunte cuál a que elija uno al azar y la factura
// termine cargada a otra empresa.
func ResolverClienteID(id uint, nombre string) (uint, error) {
if id > 0 {
return id, nil
}
nombre = strings.TrimSpace(nombre)
if nombre == "" {
return 0, fmt.Errorf("indicá el cliente: su nombre o su id")
}
items, _, err := models.GetAllClientes(50, 0, nombre)
if err != nil {
return 0, err
}
switch len(items) {
case 0:
return 0, fmt.Errorf("no encontré ningún cliente que coincida con %q; revisá el nombre o creálo con crear_cliente", nombre)
case 1:
return items[0].ID, nil
}
// Una coincidencia exacta gana sobre las parciales: "Metropolitana" no
// debería quedar ambiguo solo porque existe "Metropolitana Norte".
buscado := strings.ToLower(nombre)
for _, c := range items {
if strings.ToLower(strings.TrimSpace(c.Nombre)) == buscado ||
strings.ToLower(strings.TrimSpace(c.Empresa)) == buscado {
return c.ID, nil
}
}
var opciones []string
for i, c := range items {
if i == 8 {
opciones = append(opciones, fmt.Sprintf("y %d más", len(items)-8))
break
}
etiqueta := c.Nombre
if c.Empresa != "" && !strings.EqualFold(c.Empresa, c.Nombre) {
etiqueta = fmt.Sprintf("%s (%s)", c.Nombre, c.Empresa)
}
opciones = append(opciones, fmt.Sprintf("%d = %s", c.ID, etiqueta))
}
return 0, fmt.Errorf("hay varios clientes que coinciden con %q, preguntale al usuario cuál: %s",
nombre, strings.Join(opciones, "; "))
}
// ─── Salud de las herramientas ───────────────────────────────────────────────
// Contadores en memoria de uso y fallo por herramienta.
//
// Hasta ahora cada problema se diagnosticaba de a un caso: el usuario avisaba
// que algo no se guardó y había que reconstruir qué pasó. Con esto se ve de
// una si el fallo es de una herramienta puntual o del modelo eligiendo mal.
//
// ponytail: en memoria, se reinicia con el proceso. Alcanza para responder
// "¿qué está fallando esta semana?"; si hiciera falta histórico, va a tabla.
var (
agentStatsMu sync.Mutex
agentUsos = map[string]int{}
agentFallos = map[string]int{}
)
func RegistrarUsoHerramienta(nombre string, fallo bool) {
agentStatsMu.Lock()
defer agentStatsMu.Unlock()
agentUsos[nombre]++
if fallo {
agentFallos[nombre]++
}
}
// EstadoHerramientas devuelve el resumen ordenado por cantidad de fallos.
func EstadoHerramientas() []map[string]interface{} {
agentStatsMu.Lock()
defer agentStatsMu.Unlock()
out := make([]map[string]interface{}, 0, len(agentUsos))
for nombre, usos := range agentUsos {
out = append(out, map[string]interface{}{
"herramienta": nombre,
"usos": usos,
"fallos": agentFallos[nombre],
})
}
sort.Slice(out, func(i, j int) bool {
fi, fj := out[i]["fallos"].(int), out[j]["fallos"].(int)
if fi != fj {
return fi > fj
}
return out[i]["usos"].(int) > out[j]["usos"].(int)
})
return out
}
+64 -29
View File
@@ -134,6 +134,8 @@ func agentTools() []agentTool {
return []agentTool{
// ── Sistema ──────────────────────────────────────────────────────────
tool("estado_herramientas", "Muestra qué herramientas se usaron y cuáles fallaron desde que arrancó el sistema. Útil cuando el usuario pregunta por qué algo no se guardó o si algo viene fallando.",
obj(map[string]agentToolParam{}, nil)),
tool("sistema_info", "Información general del sistema y recursos disponibles.", obj(nil, nil)),
// ── Coolify ──────────────────────────────────────────────────────────
@@ -209,7 +211,8 @@ func agentTools() []agentTool {
}, nil)),
tool("crear_factura", "Registra una factura de VENTA a partir de datos escritos, SIN archivo adjunto: U-SITE le factura/cobra al cliente. Usa esta cuando el usuario dicta los datos de la factura. Si en cambio acaba de enviar un PDF o una foto de la factura, usa adjuntar_factura.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente al que se le factura (usa listar_clientes si no lo sabes)"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"monto": num("Monto total de la factura"),
"numero": str("Número de factura, ej: FEV 92"),
"descripcion": str("Concepto, ej: 'Hosting por 1 año'"),
@@ -236,10 +239,11 @@ func agentTools() []agentTool {
}, []string{"cuenta_id"})),
tool("adjuntar_factura", "Guarda como factura de VENTA el documento (PDF/foto) que el usuario acaba de enviar por Telegram: U-SITE es quien factura/cobra al cliente. Solo funciona si hay un archivo adjunto pendiente. Si el documento es al revés (un proveedor le factura a U-SITE), usa adjuntar_factura_compra en su lugar, no esta.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente al que pertenece la factura (usa listar_clientes si no lo sabes)"),
"numero": str("Número de factura, si el usuario lo indicó"),
"monto": num("Monto de la factura, si el usuario lo indicó"),
"descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"numero": str("Número de factura, si el usuario lo indicó"),
"monto": num("Monto de la factura, si el usuario lo indicó"),
"descripcion": str("Descripción breve, ej: 'Factura mensual octubre'"),
}, []string{"cliente_id"})),
tool("adjuntar_factura_compra", "Guarda como factura de COMPRA (cuenta por pagar) el documento que el usuario acaba de enviar por Telegram: un proveedor le está facturando a U-SITE, no al revés. Busca o crea el proveedor por nombre. Solo funciona si hay un archivo adjunto pendiente.",
obj(map[string]agentToolParam{
@@ -277,7 +281,8 @@ func agentTools() []agentTool {
obj(map[string]agentToolParam{"id": num("ID del contrato")}, []string{"id"})),
tool("crear_contrato", "Crea un contrato para un cliente con uno o más servicios y genera de una vez su PDF con cláusulas estándar.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"servicio_ids": agentToolParam{Type: "array", Description: "IDs de los servicios contratados", Items: &agentToolParam{Type: "number"}},
"duracion_meses": num("Duración del contrato en meses (default 12)"),
"precio_acordado": num("Valor total acordado"),
@@ -315,10 +320,11 @@ func agentTools() []agentTool {
}, nil)),
tool("crear_cuenta_cobro", "Crea una cuenta de cobro para un cliente/proyecto y genera de una vez su PDF de solicitud.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente"),
"descripcion": str("Descripción del cobro, ej: proyecto y periodo"),
"valor": num("Valor a cobrar"),
"notas": str("Notas adicionales"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"descripcion": str("Descripción del cobro, ej: proyecto y periodo"),
"valor": num("Valor a cobrar"),
"notas": str("Notas adicionales"),
}, []string{"cliente_id", "descripcion", "valor"})),
tool("generar_documento_cuenta_cobro", "Genera (o regenera) el PDF de una cuenta de cobro ya existente.",
obj(map[string]agentToolParam{"id": num("ID de la cuenta de cobro")}, []string{"id"})),
@@ -335,9 +341,10 @@ func agentTools() []agentTool {
}, nil)),
tool("crear_cotizacion", "Genera el PDF de una cotización para un cliente, a partir de la plantilla activa y los items con precio ya calculado (consulta listar_tarifas antes para saber los valores).",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"alcance": str("Descripción del alcance del proyecto o servicio a cotizar"),
"tipo_proyecto": str("Tipo de proyecto, ej: migracion_m365, vm_azure, soporte"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"alcance": str("Descripción del alcance del proyecto o servicio a cotizar"),
"tipo_proyecto": str("Tipo de proyecto, ej: migracion_m365, vm_azure, soporte"),
"items": agentToolParam{
Type: "array",
Description: "Ítems de la cotización con precio ya calculado",
@@ -362,7 +369,8 @@ func agentTools() []agentTool {
"requerimiento": str("Requerimiento del cliente en texto libre"),
"propuesta": str("Propuesta técnica ya redactada (HTML o texto) que se insertará en el documento"),
"nombre": str("Título de la propuesta"),
"cliente_id": num("ID del cliente (opcional)"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"guardar_como_referencia": agentToolParam{Type: "boolean", Description: "Si true, guarda esta propuesta como patrón reutilizable para el futuro"},
}, []string{"requerimiento", "propuesta"})),
@@ -374,10 +382,11 @@ func agentTools() []agentTool {
}, nil)),
tool("crear_proyecto", "Crea un nuevo proyecto para un cliente.",
obj(map[string]agentToolParam{
"cliente_id": num("ID del cliente (usa listar_clientes si no lo sabes)"),
"nombre": str("Nombre del proyecto"),
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
"descripcion": str("Descripción adicional del proyecto"),
"cliente_id": num("ID del cliente, si ya lo conocés"),
"cliente_nombre": str("Nombre o empresa del cliente, tal como lo dijo el usuario. Alcanza con esto: el servidor lo resuelve. Usalo en vez de buscar el id con listar_clientes."),
"nombre": str("Nombre del proyecto"),
"stack": str("Stack tecnológico, ej: Go + React + PostgreSQL"),
"descripcion": str("Descripción adicional del proyecto"),
}, []string{"cliente_id", "nombre"})),
tool("crear_fase_proyecto", "Agrega una fase/etapa a un proyecto existente (ej: Diseño, Desarrollo, QA, Entrega). Se puede llamar varias veces seguidas para registrar todas las fases de un proyecto de una vez.",
obj(map[string]agentToolParam{
@@ -512,6 +521,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
return ""
}
// Resuelve el cliente por id o por nombre. Ver agent_resolver.go: evita que
// el modelo tenga que encadenar listar_clientes → leer → extraer id.
resolverCliente := func() (uint, error) {
return ResolverClienteID(uint(getInt("cliente_id", 0)), getStr("cliente_nombre"))
}
getBool := func(key string) bool {
if v, ok := a[key]; ok {
if b, ok := v.(bool); ok {
@@ -691,9 +705,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
case "crear_factura":
clienteID := uint(getInt("cliente_id", 0))
if clienteID == 0 {
return nil, fmt.Errorf("cliente_id requerido")
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
monto := getFloat("monto", 0)
if monto <= 0 {
@@ -800,9 +814,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
if !ok {
return nil, fmt.Errorf("no hay ningún documento pendiente en este chat; pide al usuario que lo envíe de nuevo")
}
clienteID := uint(getInt("cliente_id", 0))
if clienteID == 0 {
return nil, fmt.Errorf("cliente_id requerido")
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
f := &models.Factura{
ClienteID: clienteID,
@@ -838,6 +852,9 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
}
return map[string]interface{}{"ok": true, "cuenta_pagar_id": cp.ID, "proveedor_id": cp.EntidadID}, nil
case "estado_herramientas":
return map[string]interface{}{"items": EstadoHerramientas()}, nil
case "listar_clientes":
page := getInt("page", 1)
search := getStr("search")
@@ -922,8 +939,12 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
servicioIDs = append(servicioIDs, uint(x))
}
}
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
contrato, doc, err := CrearContratoConDocumento(
uint(getInt("cliente_id", 0)),
clienteID,
servicioIDs,
getInt("duracion_meses", 12),
float64(getInt("precio_acordado", 0)),
@@ -1004,8 +1025,12 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
case "crear_cuenta_cobro":
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
cc, doc, err := CrearCuentaCobroConDocumento(
uint(getInt("cliente_id", 0)),
clienteID,
getStr("descripcion"),
getFloat("valor", 0),
nil,
@@ -1084,7 +1109,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
Unidad: toStr(m["unidad"]),
})
}
doc, total, err := CrearCotizacion(uint(getInt("cliente_id", 0)), getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
doc, total, err := CrearCotizacion(clienteID, getStr("alcance"), getStr("tipo_proyecto"), items, "telegram")
if err != nil {
return nil, err
}
@@ -1132,7 +1161,11 @@ func runTool(chatID int64, name string, a map[string]interface{}) (interface{},
return map[string]interface{}{"items": items, "total": total, "page": page}, nil
case "crear_proyecto":
p, err := CrearProyectoSimple(uint(getInt("cliente_id", 0)), getStr("nombre"), getStr("stack"), getStr("descripcion"))
clienteID, err := resolverCliente()
if err != nil {
return nil, err
}
p, err := CrearProyectoSimple(clienteID, getStr("nombre"), getStr("stack"), getStr("descripcion"))
if err != nil {
return nil, err
}
@@ -1895,7 +1928,9 @@ Comandos: /reset · /instancias · /ayuda`, nil
// guardó algo aunque la herramienta haya devuelto un error, así que
// el log es la única fuente confiable de qué pasó de verdad.
log.Printf("[AGENT] Resultado de %s: %s", tc.Function.Name, recortar(toolResult, 500))
if msg := errorDeTool(toolResult); msg != "" {
msg := errorDeTool(toolResult)
RegistrarUsoHerramienta(tc.Function.Name, msg != "")
if msg != "" {
fallidas = append(fallidas, fmt.Sprintf("%s: %s", tc.Function.Name, msg))
}
+32
View File
@@ -65,3 +65,35 @@ func TestErrorDeTool(t *testing.T) {
}
}
}
// El id explícito manda: si el modelo ya lo tiene, no hay que ir a la base.
// Es el único camino del resolvedor que se puede probar sin datos.
func TestResolverClienteIDPrefiereElIDExplicito(t *testing.T) {
id, err := ResolverClienteID(42, "cualquier nombre")
if err != nil || id != 42 {
t.Fatalf("= (%d, %v), esperaba (42, nil)", id, err)
}
}
// Sin id ni nombre no se puede adivinar: mejor un error claro que buscar en la
// base con la cadena vacía y traer un cliente al azar.
func TestResolverClienteIDSinDatos(t *testing.T) {
if _, err := ResolverClienteID(0, " "); err == nil {
t.Fatal("esperaba error cuando no hay ni id ni nombre")
}
}
// Las herramientas que piden cliente deben aceptar también el nombre: es lo
// que elimina el ciclo listar_clientes → leer → extraer id, que es donde se
// perdía la factura.
func TestHerramientasDeClienteAceptanNombre(t *testing.T) {
for _, tl := range agentTools() {
props := tl.Function.Parameters.Properties
if _, pide := props["cliente_id"]; !pide {
continue
}
if _, acepta := props["cliente_nombre"]; !acepta {
t.Errorf("%s pide cliente_id pero no acepta cliente_nombre: obliga al modelo a resolver el id por su cuenta", tl.Function.Name)
}
}
}