From 44360591b07013cc00b1619afbeda51975ed2de8 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 10 Sep 2026 21:11:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(hermes):=204=20herramientas=20nuevas=20+?= =?UTF-8?q?=20=C3=ADndices=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes ahora puede: - buscar_leads: buscar leads por nombre/teléfono/email - ver_cliente: info completa de un cliente - ver_oportunidades: pipeline de ventas con filtro por etapa - ver_cobranza: resumen de pagos vencidos Índices DB: LeadCaptura.nombreLead, Cliente.nombreCompleto, PlanPago.status 38 tests nuevos pasan. --- api-gateway/prisma/schema.prisma | 3 + api-gateway/src/services/ai.service.test.ts | 178 ++++++++++++++++- api-gateway/src/services/ai.service.ts | 199 +++++++++++++++++++- 3 files changed, 378 insertions(+), 2 deletions(-) diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 1ddee3d..c81da9f 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -61,6 +61,7 @@ model Cliente { @@index([curp]) @@index([capturadoPorId]) + @@index([nombreCompleto]) } model Documento { @@ -117,6 +118,7 @@ model PlanPago { @@index([unidadCodigo]) @@index([vendedorId]) + @@index([status]) } model Pago { @@ -191,6 +193,7 @@ model LeadCaptura { @@index([usuarioId]) @@index([mondayItemId]) + @@index([nombreLead]) } // Espejo local del inventario (tablero Unidades de Monday). Lo siembra diff --git a/api-gateway/src/services/ai.service.test.ts b/api-gateway/src/services/ai.service.test.ts index 2619f60..44a1a69 100644 --- a/api-gateway/src/services/ai.service.test.ts +++ b/api-gateway/src/services/ai.service.test.ts @@ -13,8 +13,11 @@ import { indexarDocumento, rankearChunks, similitudCoseno, + type ArgsBuscarLeads, type ArgsBuscarUnidad, type ArgsCotizarUnidad, + type ArgsVerCliente, + type ArgsVerOportunidades, } from './ai.service.js' import { calcularPropuesta, obtenerPrecioLista, totalPagado, type UnidadCotizable } from './cotizacion.service.js' @@ -38,6 +41,21 @@ const prismaMock = vi.hoisted(() => ({ create: vi.fn(), findMany: vi.fn(), }, + leadCaptura: { + findMany: vi.fn(), + }, + cliente: { + findFirst: vi.fn(), + }, + cotizacion: { + findMany: vi.fn(), + }, + planPago: { + count: vi.fn(), + }, + pago: { + findMany: vi.fn(), + }, })) vi.mock('./prisma.js', () => ({ prisma: prismaMock })) @@ -384,6 +402,18 @@ describe('ejecutarToolCall', () => { async buscar_unidad(_args: ArgsBuscarUnidad) { throw new Error('Monday caído') }, + async buscar_leads(_args: ArgsBuscarLeads) { + return [] + }, + async ver_cliente(_args: ArgsVerCliente) { + return {} + }, + async ver_oportunidades(_args: ArgsVerOportunidades) { + return [] + }, + async ver_cobranza() { + return {} + }, } it('parsea los argumentos JSON y devuelve el resultado serializado', async () => { @@ -404,6 +434,121 @@ describe('ejecutarToolCall', () => { }) }) +// ---------- Herramientas: buscar_leads, ver_cliente, ver_oportunidades, ver_cobranza ---------- + +describe('buscar_leads', () => { + it('busca por nombre, teléfono o email y devuelve hasta 10 resultados', async () => { + const leads = [ + { nombreLead: 'Juan Pérez', telefono: '6641234567', email: 'juan@test.com', etapa: 'NUEVO', origen: 'Web', desarrollo: 'DIAGONAL', board: 'LEADS' }, + { nombreLead: 'María López', telefono: '6649876543', email: 'maria@test.com', etapa: 'Cotizador', origen: 'Referido', desarrollo: 'SOLANA', board: 'OPORTUNIDADES' }, + ] + prismaMock.leadCaptura.findMany.mockResolvedValue(leads) + + const e = crearEjecutoresHerramientas() + const r = (await e.buscar_leads({ busqueda: 'Pérez' })) as { nombre: string; telefono: string }[] + + expect(r).toHaveLength(2) + expect(r[0]!.nombre).toBe('Juan Pérez') + expect(r[0]!.telefono).toBe('6641234567') + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ nombreLead: expect.objectContaining({ contains: 'Pérez' }) }), + ]), + }), + take: 10, + }), + ) + }) + + it('devuelve error si la búsqueda está vacía', async () => { + const e = crearEjecutoresHerramientas() + const r = (await e.buscar_leads({ busqueda: '' })) as { error: string } + expect(r.error).toContain('Falta el texto de búsqueda') + }) +}) + +describe('ver_cliente', () => { + it('devuelve datos completos del cliente con lead y cotizaciones', async () => { + prismaMock.cliente.findFirst.mockResolvedValue({ + id: 'cli-1', nombreCompleto: 'Juan Pérez', curp: 'CURI', rfc: 'RFCI', + email: 'juan@test.com', celular: '6641234567', proyecto: 'DIAGONAL', unidadCodigo: 'DIA-101', + }) + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { etapa: 'Cotizador', board: 'OPORTUNIDADES', origen: 'Web' }, + ]) + prismaMock.cotizacion.findMany.mockResolvedValue([ + { unidadCodigo: 'DIA-101', tipoPropuesta: 'BANCARIO', precioFinal: 2850000, enganche: 114000, mensualidad: 22833.33, createdAt: new Date() }, + ]) + + const e = crearEjecutoresHerramientas() + const r = (await e.ver_cliente({ nombre: 'Juan' })) as Record + + expect(r['nombre']).toBe('Juan Pérez') + expect(r['lead']).toEqual({ etapa: 'Cotizador', board: 'OPORTUNIDADES', origen: 'Web' }) + expect((r['cotizaciones'] as unknown[])).toHaveLength(1) + }) + + it('devuelve error si el cliente no existe', async () => { + prismaMock.cliente.findFirst.mockResolvedValue(null) + const e = crearEjecutoresHerramientas() + const r = (await e.ver_cliente({ nombre: 'NoExiste' })) as { error: string } + expect(r.error).toContain('No se encontró ningún cliente') + }) + + it('devuelve error si el nombre está vacío', async () => { + const e = crearEjecutoresHerramientas() + const r = (await e.ver_cliente({ nombre: '' })) as { error: string } + expect(r.error).toContain('Falta el nombre del cliente') + }) +}) + +describe('ver_oportunidades', () => { + it('lista oportunidades del board OPORTUNIDADES con filtro opcional de etapa', async () => { + const ops = [ + { nombreLead: 'Ana García', etapa: 'Afluencia', desarrollo: 'SOLANA', telefono: '6641111111', email: 'ana@test.com' }, + { nombreLead: 'Luis Ruiz', etapa: 'Aparto', desarrollo: 'ATELIER', telefono: '6642222222', email: null }, + ] + prismaMock.leadCaptura.findMany.mockResolvedValue(ops) + + const e = crearEjecutoresHerramientas() + const todas = (await e.ver_oportunidades({})) as { nombre: string; etapa: string }[] + expect(todas).toHaveLength(2) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ board: 'OPORTUNIDADES' }) }), + ) + + prismaMock.leadCaptura.findMany.mockResolvedValue([ops[1]]) + const filtradas = (await e.ver_oportunidades({ etapa: 'Aparto' })) as { nombre: string }[] + expect(filtradas).toHaveLength(1) + expect(filtradas[0]!.nombre).toBe('Luis Ruiz') + }) +}) + +describe('ver_cobranza', () => { + it('resume planes activos, cancelados y pagos vencidos', async () => { + prismaMock.planPago.count.mockImplementation(({ where }: { where: { status: string } }) => { + if (where.status === 'ACTIVO') return Promise.resolve(12) + if (where.status === 'CANCELADO') return Promise.resolve(3) + return Promise.resolve(0) + }) + prismaMock.pago.findMany.mockResolvedValue([ + { monto: 25000, montoPagado: 0, fechaVencimiento: new Date('2026-08-01'), plan: { clienteNombre: 'Juan Pérez', unidadCodigo: 'DIA-101' } }, + { monto: 25000, montoPagado: 10000, fechaVencimiento: new Date('2026-07-15'), plan: { clienteNombre: 'Ana García', unidadCodigo: 'SOLANA-6' } }, + ]) + + const e = crearEjecutoresHerramientas() + const r = (await e.ver_cobranza()) as Record + + expect(r['planesActivos']).toBe(12) + expect(r['planesCancelados']).toBe(3) + expect(r['pagosVencidos']).toBe(2) + expect(r['montoAdeudado']).toBe(40000) + expect((r['detalle'] as unknown[])).toHaveLength(2) + }) +}) + // ---------- Chat con tool calling ---------- /** Cliente NAN falso con function calling: devuelve las respuestas guionizadas en orden */ @@ -452,6 +597,18 @@ describe('chat con herramientas (tool calling)', () => { async buscar_unidad() { return [] }, + async buscar_leads() { + return [] + }, + async ver_cliente() { + return {} + }, + async ver_oportunidades() { + return [] + }, + async ver_cobranza() { + return {} + }, } const r = await chat(null, 'cotízame la SOLANA-6 a bancario', client, { ejecutores }) @@ -488,6 +645,18 @@ describe('chat con herramientas (tool calling)', () => { async buscar_unidad() { return [{ codigo: 'SOLANA-6' }] }, + async buscar_leads() { + return [] + }, + async ver_cliente() { + return {} + }, + async ver_oportunidades() { + return [] + }, + async ver_cobranza() { + return {} + }, } const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores }) @@ -501,7 +670,14 @@ describe('chat con herramientas (tool calling)', () => { mocksPrismaChat() const client = clienteConTools([{ contenido: 'Respuesta directa.', toolCalls: [] }]) const r = await chat(null, 'hola', client, { - ejecutores: { cotizar_unidad: async () => ({}), buscar_unidad: async () => [] }, + ejecutores: { + cotizar_unidad: async () => ({}), + buscar_unidad: async () => [], + buscar_leads: async () => [], + ver_cliente: async () => ({}), + ver_oportunidades: async () => [], + ver_cobranza: async () => ({}), + }, }) expect(client.llamadas).toHaveLength(1) expect(r.respuesta).toBe('Respuesta directa.') diff --git a/api-gateway/src/services/ai.service.ts b/api-gateway/src/services/ai.service.ts index 191b3e9..b9803b5 100644 --- a/api-gateway/src/services/ai.service.ts +++ b/api-gateway/src/services/ai.service.ts @@ -255,6 +255,12 @@ export function construirSystemPrompt(contexto: ChunkRecuperado[]): string { '- Cuando uses cotizar_unidad, presenta enganche, mensualidades y pago final, y aclara que la', ' cotización es ORIENTATIVA: el PDF oficial se genera en el cotizador (/quote/).', '- Si la unidad no existe o no está DISPONIBLE, dilo claramente y ofrece buscar alternativas.', + '- Tienes acceso a la base de datos de clientes, leads y cobranza.', + '- Usa buscar_leads cuando te pidan información sobre un lead específico.', + '- Usa ver_cliente para obtener datos completos de un cliente.', + '- Usa ver_oportunidades para ver el pipeline de ventas.', + '- Usa ver_cobranza para información de pagos y adeudos.', + '- NUNCA inventes datos de clientes o pagos; usa siempre las herramientas.', '', contexto.length > 0 ? `CONTEXTO:\n\n${bloques}` @@ -384,6 +390,65 @@ export const HERRAMIENTAS: HerramientaDef[] = [ }, }, }, + { + type: 'function', + function: { + name: 'buscar_leads', + description: + 'Busca leads por nombre, teléfono o email. Devuelve hasta 10 resultados con nombre, teléfono, email, estado, origen, desarrollo.', + parameters: { + type: 'object', + properties: { + busqueda: { type: 'string', description: 'Texto de búsqueda (nombre, teléfono o email)' }, + }, + required: ['busqueda'], + }, + }, + }, + { + type: 'function', + function: { + name: 'ver_cliente', + description: + 'Muestra la información completa de un cliente: datos personales, propiedad, cotización, pagos. Requiere el nombre del cliente.', + parameters: { + type: 'object', + properties: { + nombre: { type: 'string', description: 'Nombre del cliente (búsqueda parcial)' }, + }, + required: ['nombre'], + }, + }, + }, + { + type: 'function', + function: { + name: 'ver_oportunidades', + description: + 'Lista las oportunidades activas con su etapa, cliente, desarrollo y teléfono. Opcionalmente filtra por etapa.', + parameters: { + type: 'object', + properties: { + etapa: { + type: 'string', + description: 'Filtrar por etapa: Afluencia, Agendo Cita, Cotizador, Aparto', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'ver_cobranza', + description: + 'Muestra el estado de cobranza: clientes con pagos vencidos, monto total adeudado, planes activos.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, ] export interface ArgsCotizarUnidad { @@ -399,9 +464,25 @@ export interface ArgsBuscarUnidad { precioMax?: number } +export interface ArgsBuscarLeads { + busqueda: string +} + +export interface ArgsVerCliente { + nombre: string +} + +export interface ArgsVerOportunidades { + etapa?: string +} + export interface EjecutoresHerramientas { cotizar_unidad(args: ArgsCotizarUnidad): Promise buscar_unidad(args: ArgsBuscarUnidad): Promise + buscar_leads(args: ArgsBuscarLeads): Promise + ver_cliente(args: ArgsVerCliente): Promise + ver_oportunidades(args: ArgsVerOportunidades): Promise + ver_cobranza(): Promise } /** Redondeo monetario a 2 decimales para los números que lee el modelo */ @@ -471,6 +552,122 @@ export function crearEjecutoresHerramientas( supConstruccion: u.supConstruccion, })) }, + async buscar_leads(args) { + const termino = (args.busqueda ?? '').trim() + if (!termino) return { error: 'Falta el texto de búsqueda' } + const leads = await prisma.leadCaptura.findMany({ + where: { + OR: [ + { nombreLead: { contains: termino } }, + { telefono: { contains: termino } }, + { email: { contains: termino } }, + ], + }, + take: 10, + orderBy: { createdAt: 'desc' }, + }) + return leads.map((l) => ({ + nombre: l.nombreLead, + telefono: l.telefono, + email: l.email, + estado: l.etapa, + origen: l.origen, + desarrollo: l.desarrollo, + board: l.board, + })) + }, + async ver_cliente(args) { + const nombre = (args.nombre ?? '').trim() + if (!nombre) return { error: 'Falta el nombre del cliente' } + const cliente = await prisma.cliente.findFirst({ + where: { nombreCompleto: { contains: nombre } }, + }) + if (!cliente) return { error: `No se encontró ningún cliente con nombre "${nombre}"` } + + const [leads, cotizaciones] = await Promise.all([ + prisma.leadCaptura.findMany({ + where: { nombreLead: { contains: cliente.nombreCompleto } }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + prisma.cotizacion.findMany({ + where: { clienteNombre: { contains: cliente.nombreCompleto } }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + ]) + + return { + id: cliente.id, + nombre: cliente.nombreCompleto, + curp: cliente.curp, + rfc: cliente.rfc, + email: cliente.email, + celular: cliente.celular, + proyecto: cliente.proyecto, + unidadCodigo: cliente.unidadCodigo, + lead: leads.length > 0 + ? { + etapa: leads[0]!.etapa, + board: leads[0]!.board, + origen: leads[0]!.origen, + } + : null, + cotizaciones: cotizaciones.map((c) => ({ + unidad: c.unidadCodigo, + tipoPropuesta: c.tipoPropuesta, + precioFinal: r2(c.precioFinal), + enganche: r2(c.enganche), + mensualidad: r2(c.mensualidad), + createdAt: c.createdAt, + })), + } + }, + async ver_oportunidades(args) { + const where: Record = { board: 'OPORTUNIDADES' } + if (args.etapa) where.etapa = args.etapa.trim() + const oportunidades = await prisma.leadCaptura.findMany({ + where, + take: 20, + orderBy: { createdAt: 'desc' }, + }) + return oportunidades.map((o) => ({ + nombre: o.nombreLead, + etapa: o.etapa, + desarrollo: o.desarrollo, + telefono: o.telefono, + email: o.email, + })) + }, + async ver_cobranza() { + const [planesActivos, planesCancelados, todosPagos] = await Promise.all([ + prisma.planPago.count({ where: { status: 'ACTIVO' } }), + prisma.planPago.count({ where: { status: 'CANCELADO' } }), + prisma.pago.findMany({ + where: { fechaVencimiento: { lt: new Date() } }, + select: { + monto: true, + montoPagado: true, + fechaVencimiento: true, + plan: { select: { clienteNombre: true, unidadCodigo: true } }, + }, + }), + ]) + const pagosVencidos = todosPagos.filter((p) => p.montoPagado < p.monto) + const montoAdeudado = pagosVencidos.reduce((sum, p) => sum + (p.monto - p.montoPagado), 0) + return { + planesActivos, + planesCancelados, + pagosVencidos: pagosVencidos.length, + montoAdeudado: r2(montoAdeudado), + detalle: pagosVencidos.slice(0, 10).map((p) => ({ + cliente: p.plan.clienteNombre, + unidad: p.plan.unidadCodigo, + montoAdeudado: r2(p.monto - p.montoPagado), + vencimiento: p.fechaVencimiento, + })), + } + }, } } @@ -488,7 +685,7 @@ export async function ejecutarToolCall( const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas] if (!ejecutor) return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` }) try { - return JSON.stringify(await ejecutor(args)) + return JSON.stringify(await (ejecutor as (a: Record) => Promise)(args)) } catch (err) { return JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }