From e2440b04f19c7db001cf9fdc9f0ac736a1157877 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:24:06 -0500 Subject: [PATCH] feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/services/agent_resolver.go | 121 +++++++++++++++++++++++++ pkg/services/telegram_agent_service.go | 93 +++++++++++++------ pkg/services/telegram_tools_test.go | 32 +++++++ 3 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 pkg/services/agent_resolver.go diff --git a/pkg/services/agent_resolver.go b/pkg/services/agent_resolver.go new file mode 100644 index 0000000..074c42c --- /dev/null +++ b/pkg/services/agent_resolver.go @@ -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 +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index e0c4049..1b7708b 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -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)) } diff --git a/pkg/services/telegram_tools_test.go b/pkg/services/telegram_tools_test.go index 101555a..351b46e 100644 --- a/pkg/services/telegram_tools_test.go +++ b/pkg/services/telegram_tools_test.go @@ -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) + } + } +}