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:
2026-09-10 21:11:01 +00:00
parent 88babe9913
commit 44360591b0
3 changed files with 378 additions and 2 deletions

View File

@@ -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.')