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

@@ -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

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

View File

@@ -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<unknown>
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
buscar_leads(args: ArgsBuscarLeads): Promise<unknown>
ver_cliente(args: ArgsVerCliente): Promise<unknown>
ver_oportunidades(args: ArgsVerOportunidades): Promise<unknown>
ver_cobranza(): Promise<unknown>
}
/** 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<string, unknown> = { 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<string, unknown>) => Promise<unknown>)(args))
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
}