feat(hermes): 4 herramientas nuevas + índices DB
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.
This commit is contained in:
@@ -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<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
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.')
|
||||
|
||||
Reference in New Issue
Block a user