14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
702 lines
26 KiB
TypeScript
702 lines
26 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
|
|
import {
|
|
HERRAMIENTAS,
|
|
chat,
|
|
construirSystemPrompt,
|
|
crearEjecutoresHerramientas,
|
|
decidirIndexacion,
|
|
dedupeFuentes,
|
|
ejecutarToolCall,
|
|
fragmentar,
|
|
hashContenido,
|
|
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'
|
|
|
|
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
|
|
|
|
const prismaMock = vi.hoisted(() => ({
|
|
aIDocumento: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
aIChunk: {
|
|
deleteMany: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
aIConversacion: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
aIMensaje: {
|
|
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 }))
|
|
|
|
/** Cliente NAN falso: embeddings deterministas por longitud y chat eco */
|
|
function clienteFalso(): NanClient & { llamadasEmbed: string[][] } {
|
|
const llamadasEmbed: string[][] = []
|
|
return {
|
|
llamadasEmbed,
|
|
async embed(textos: string[]) {
|
|
llamadasEmbed.push(textos)
|
|
return textos.map((t) => [t.length, t.length % 7, 1])
|
|
},
|
|
async chat() {
|
|
return 'Respuesta simulada del asistente.\n\nFuentes: Doc de prueba'
|
|
},
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ---------- Chunking ----------
|
|
|
|
describe('fragmentar', () => {
|
|
it('devuelve un solo chunk para textos cortos', () => {
|
|
const chunks = fragmentar('Hola mundo.\n\nSegundo párrafo.')
|
|
expect(chunks).toHaveLength(1)
|
|
expect(chunks[0]).toContain('Hola mundo')
|
|
expect(chunks[0]).toContain('Segundo párrafo')
|
|
})
|
|
|
|
it('divide por párrafos respetando el tamaño máximo', () => {
|
|
const parrafos = Array.from({ length: 10 }, (_, i) => `Párrafo ${i}: ${'x'.repeat(100)}`)
|
|
const chunks = fragmentar(parrafos.join('\n\n'), 400, 50)
|
|
expect(chunks.length).toBeGreaterThan(2)
|
|
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(400 + 50)
|
|
})
|
|
|
|
it('aplica solape entre chunks consecutivos', () => {
|
|
const parrafos = Array.from({ length: 6 }, (_, i) => `P${i} ${'abcde'.repeat(30)}`)
|
|
const chunks = fragmentar(parrafos.join('\n\n'), 350, 60)
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
// el inicio de un chunk comparte caracteres con el final del anterior
|
|
const cola = chunks[0]!.slice(-30)
|
|
expect(chunks[1]!.includes(cola)).toBe(true)
|
|
})
|
|
|
|
it('corta duro un párrafo que excede el tamaño máximo sin perder texto', () => {
|
|
const largo = 'y'.repeat(1500)
|
|
const chunks = fragmentar(largo, 500, 100)
|
|
expect(chunks.length).toBeGreaterThanOrEqual(3)
|
|
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500)
|
|
// sin solape habría 1500 chars útiles; con solape el total crece pero nada se pierde
|
|
expect(chunks.join('').length).toBeGreaterThanOrEqual(1500)
|
|
})
|
|
|
|
it('ignora párrafos vacíos', () => {
|
|
expect(fragmentar('\n\n\n \n\n')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// ---------- Coseno y ranking ----------
|
|
|
|
describe('similitudCoseno', () => {
|
|
it('es 1 para vectores idénticos y 0 para ortogonales', () => {
|
|
expect(similitudCoseno([1, 2, 3], [1, 2, 3])).toBeCloseTo(1)
|
|
expect(similitudCoseno([1, 0], [0, 1])).toBeCloseTo(0)
|
|
})
|
|
|
|
it('es invariante a la escala', () => {
|
|
expect(similitudCoseno([1, 1], [5, 5])).toBeCloseTo(1)
|
|
})
|
|
|
|
it('devuelve 0 si algún vector es cero', () => {
|
|
expect(similitudCoseno([0, 0], [1, 1])).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('rankearChunks', () => {
|
|
const chunks = [
|
|
{ titulo: 'A', texto: 'a', embedding: [1, 0] },
|
|
{ titulo: 'B', texto: 'b', embedding: [0.9, 0.1] },
|
|
{ titulo: 'C', texto: 'c', embedding: [0, 1] },
|
|
{ titulo: 'D', texto: 'd', embedding: [0.8, 0.2] },
|
|
]
|
|
|
|
it('ordena por score descendente y aplica top-k', () => {
|
|
const top = rankearChunks(chunks, [1, 0], 2, 0)
|
|
expect(top.map((t) => t.titulo)).toEqual(['A', 'B'])
|
|
})
|
|
|
|
it('filtra por el umbral mínimo', () => {
|
|
// coseno([1,0],[0.9,0.1]) ≈ 0.994 y coseno([1,0],[0.8,0.2]) ≈ 0.970
|
|
const top = rankearChunks(chunks, [1, 0], 4, 0.995)
|
|
expect(top.map((t) => t.titulo)).toEqual(['A'])
|
|
})
|
|
|
|
it('devuelve vacío si nada supera el umbral', () => {
|
|
expect(rankearChunks(chunks, [0, 1], 4, 0.999)).toEqual([{ titulo: 'C', texto: 'c', score: 1 }])
|
|
expect(rankearChunks(chunks, [0, 1], 4, 1.0001)).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ---------- System prompt ----------
|
|
|
|
describe('construirSystemPrompt', () => {
|
|
it('incluye el contexto numerado con títulos de fuente y las reglas', () => {
|
|
const prompt = construirSystemPrompt([
|
|
{ titulo: 'Catálogo DIAGONAL', texto: 'Modelo 1R desde $2,850,000', score: 0.9 },
|
|
{ titulo: 'Proceso de venta', texto: 'Apartado de $20,000', score: 0.8 },
|
|
])
|
|
expect(prompt).toContain('asistente interno de Frasa Desarrollos')
|
|
expect(prompt).toContain('Responde SOLO con base en el CONTEXTO')
|
|
expect(prompt).toContain('[1] Fuente: Catálogo DIAGONAL')
|
|
expect(prompt).toContain('[2] Fuente: Proceso de venta')
|
|
expect(prompt).toContain('Modelo 1R desde $2,850,000')
|
|
expect(prompt).toContain('Fuentes:')
|
|
})
|
|
|
|
it('marca explícitamente el contexto vacío', () => {
|
|
const prompt = construirSystemPrompt([])
|
|
expect(prompt).toContain('CONTEXTO: (vacío')
|
|
})
|
|
})
|
|
|
|
// ---------- Dedup por hash ----------
|
|
|
|
describe('hashContenido / decidirIndexacion', () => {
|
|
it('el hash es estable y cambia con el contenido', () => {
|
|
expect(hashContenido('abc')).toBe(hashContenido('abc'))
|
|
expect(hashContenido('abc')).not.toBe(hashContenido('abd'))
|
|
})
|
|
|
|
it('decide crear/actualizar/omitir según existencia y hash', () => {
|
|
expect(decidirIndexacion(null, 'h1')).toBe('crear')
|
|
expect(decidirIndexacion({ hash: 'h1' }, 'h1')).toBe('omitir')
|
|
expect(decidirIndexacion({ hash: 'h1' }, 'h2')).toBe('actualizar')
|
|
})
|
|
})
|
|
|
|
describe('indexarDocumento (dedup por hash)', () => {
|
|
it('crea un documento nuevo con sus chunks embebidos', async () => {
|
|
prismaMock.aIDocumento.findUnique.mockResolvedValue(null)
|
|
prismaMock.aIDocumento.create.mockResolvedValue({ id: 'doc-1' })
|
|
const client = clienteFalso()
|
|
|
|
const r = await indexarDocumento('Doc de prueba', 'Párrafo único de prueba.', client)
|
|
|
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'creado', chunks: 1 })
|
|
expect(client.llamadasEmbed).toEqual([['Párrafo único de prueba.']])
|
|
expect(prismaMock.aIDocumento.create).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('omite la re-indexación si el hash no cambió (no llama embeddings)', async () => {
|
|
const contenido = 'Contenido sin cambios.'
|
|
prismaMock.aIDocumento.findUnique.mockResolvedValue({
|
|
id: 'doc-1',
|
|
hash: hashContenido(contenido),
|
|
chunks: [{ id: 'c1' }, { id: 'c2' }],
|
|
})
|
|
const client = clienteFalso()
|
|
|
|
const r = await indexarDocumento('Doc de prueba', contenido, client)
|
|
|
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'sin-cambios', chunks: 2 })
|
|
expect(client.llamadasEmbed).toHaveLength(0)
|
|
expect(prismaMock.aIDocumento.create).not.toHaveBeenCalled()
|
|
expect(prismaMock.aIDocumento.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('re-indexa borrando chunks viejos si el hash cambió', async () => {
|
|
prismaMock.aIDocumento.findUnique.mockResolvedValue({
|
|
id: 'doc-1',
|
|
hash: 'hash-viejo',
|
|
chunks: [{ id: 'c1' }],
|
|
})
|
|
prismaMock.aIDocumento.update.mockResolvedValue({ id: 'doc-1' })
|
|
const client = clienteFalso()
|
|
|
|
const r = await indexarDocumento('Doc de prueba', 'Contenido nuevo.', client)
|
|
|
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'actualizado', chunks: 1 })
|
|
expect(prismaMock.aIChunk.deleteMany).toHaveBeenCalledWith({ where: { documentoId: 'doc-1' } })
|
|
expect(prismaMock.aIDocumento.update).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
// ---------- Chat RAG ----------
|
|
|
|
describe('chat', () => {
|
|
it('crea conversación nueva, recupera contexto y persiste ambos mensajes', async () => {
|
|
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-1', createdAt: new Date() })
|
|
prismaMock.aIChunk.findMany.mockResolvedValue([
|
|
{ texto: 'Precios DIAGONAL', embedding: JSON.stringify([16, 2, 1]), documento: { titulo: 'Catálogo' } },
|
|
{ texto: 'Otro tema', embedding: JSON.stringify([1, 0, 500]), documento: { titulo: 'FAQ' } },
|
|
])
|
|
prismaMock.aIMensaje.findMany.mockResolvedValue([
|
|
{ rol: 'user', contenido: '¿precios?' },
|
|
])
|
|
prismaMock.aIMensaje.create.mockResolvedValue({})
|
|
const client = clienteFalso()
|
|
|
|
const r = await chat(null, '¿precios?', client)
|
|
|
|
expect(r.sessionId).toBe('conv-1')
|
|
expect(r.respuesta).toContain('Respuesta simulada')
|
|
// '¿precios?' tiene longitud 9 → embedding [9, 2, 1]; el chunk "Precios" [16,2,1] es el más cercano
|
|
expect(r.fuentes.length).toBeGreaterThan(0)
|
|
// se persisten el mensaje del usuario y el del asistente
|
|
expect(prismaMock.aIMensaje.create).toHaveBeenCalledTimes(2)
|
|
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
|
|
expect(roles).toEqual(['user', 'assistant'])
|
|
})
|
|
|
|
it('reutiliza la conversación existente y falla con sessionId desconocido', async () => {
|
|
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce({ id: 'conv-9', createdAt: new Date() })
|
|
prismaMock.aIChunk.findMany.mockResolvedValue([])
|
|
prismaMock.aIMensaje.findMany.mockResolvedValue([{ rol: 'user', contenido: 'hola' }])
|
|
prismaMock.aIMensaje.create.mockResolvedValue({})
|
|
const client = clienteFalso()
|
|
|
|
const r = await chat('conv-9', 'hola', client)
|
|
expect(r.sessionId).toBe('conv-9')
|
|
expect(prismaMock.aIConversacion.create).not.toHaveBeenCalled()
|
|
|
|
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce(null)
|
|
await expect(chat('no-existe', 'hola', client)).rejects.toThrow('Conversación no encontrada')
|
|
})
|
|
})
|
|
|
|
describe('dedupeFuentes', () => {
|
|
it('conserva el mejor score por título', () => {
|
|
const fuentes = dedupeFuentes([
|
|
{ titulo: 'A', texto: '', score: 0.5 },
|
|
{ titulo: 'A', texto: '', score: 0.91234 },
|
|
{ titulo: 'B', texto: '', score: 0.7 },
|
|
])
|
|
expect(fuentes).toEqual([
|
|
{ titulo: 'A', score: 0.912 },
|
|
{ titulo: 'B', score: 0.7 },
|
|
])
|
|
})
|
|
})
|
|
|
|
// ---------- Herramientas: cotizar_unidad / buscar_unidad ----------
|
|
|
|
const unidadSolana: UnidadCotizable = {
|
|
codigo: 'SOLANA-6',
|
|
modelo: 'Modelo A',
|
|
fechaEntrega: null,
|
|
supEstacionamiento: 12,
|
|
supConstruccion: 60,
|
|
supExcedente: 0,
|
|
precioSinAcabados: 2_000_000,
|
|
precioConAcabados: 2_100_000,
|
|
status: 'DISPONIBLE',
|
|
desarrollo: 'SOLANA',
|
|
mesesParaFirma: 12,
|
|
etapa: null,
|
|
}
|
|
|
|
const unidadApartada: UnidadCotizable = { ...unidadSolana, codigo: 'SOLANA-7', status: 'APARTADO' }
|
|
const unidadDiagonal: UnidadCotizable = {
|
|
...unidadSolana,
|
|
codigo: 'DIA-101',
|
|
modelo: 'Loft 1R',
|
|
desarrollo: 'DIAGONAL',
|
|
precioSinAcabados: 2_850_000,
|
|
fechaEntrega: '2027-06-30',
|
|
mesesParaFirma: null,
|
|
}
|
|
|
|
function ejecutoresCon(unidades: UnidadCotizable[]) {
|
|
return crearEjecutoresHerramientas(async () => unidades)
|
|
}
|
|
|
|
describe('crearEjecutoresHerramientas', () => {
|
|
it('cotizar_unidad devuelve los números del motor para una unidad DISPONIBLE', async () => {
|
|
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({ codigo: 'solana-6' })) as Record<string, unknown>
|
|
const esperada = calcularPropuesta(unidadSolana, 'BANCARIO', false, 20000)
|
|
expect(r['error']).toBeUndefined()
|
|
expect(r['unidad']).toBe('SOLANA-6')
|
|
expect(r['enganche']).toBeCloseTo(esperada.enganche, 2)
|
|
expect(r['mensualidad']).toBeCloseTo(esperada.mensualidad!, 2)
|
|
expect(r['pagoFinal']).toBeCloseTo(esperada.pagoFinal, 2)
|
|
expect(r['precioLista']).toBe(obtenerPrecioLista(unidadSolana, false))
|
|
expect(r['totalPagado']).toBeCloseTo(totalPagado(esperada, 20000)!, 2)
|
|
expect(String(r['nota'])).toContain('orientativa')
|
|
})
|
|
|
|
it('cotizar_unidad: unidad inexistente o no DISPONIBLE devuelve error claro', async () => {
|
|
const e = ejecutoresCon([unidadApartada])
|
|
expect(await e.cotizar_unidad({ codigo: 'NOPE-1' })).toEqual({
|
|
error: 'La unidad NOPE-1 no existe en el inventario',
|
|
})
|
|
const r = (await e.cotizar_unidad({ codigo: 'SOLANA-7' })) as { error: string }
|
|
expect(r.error).toContain('no está DISPONIBLE')
|
|
expect(r.error).toContain('APARTADO')
|
|
})
|
|
|
|
it('cotizar_unidad: tipoPropuesta inválido devuelve error con los válidos', async () => {
|
|
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({
|
|
codigo: 'SOLANA-6',
|
|
tipoPropuesta: 'leasing',
|
|
})) as { error: string }
|
|
expect(r.error).toContain('tipoPropuesta inválido')
|
|
expect(r.error).toContain('BANCARIO')
|
|
})
|
|
|
|
it('buscar_unidad filtra por desarrollo, modelo y precioMax; solo DISPONIBLE, máx 5', async () => {
|
|
const muchas = Array.from({ length: 8 }, (_, i) => ({
|
|
...unidadSolana,
|
|
codigo: `SOLANA-${i + 1}`,
|
|
}))
|
|
const e = ejecutoresCon([unidadSolana, unidadApartada, unidadDiagonal, ...muchas])
|
|
|
|
// solo DISPONIBLE y tope de 5
|
|
const todas = (await e.buscar_unidad({})) as { codigo: string }[]
|
|
expect(todas).toHaveLength(5)
|
|
expect(todas.every((u) => u.codigo !== 'SOLANA-7')).toBe(true)
|
|
|
|
// por desarrollo
|
|
const diagonal = (await e.buscar_unidad({ desarrollo: 'diagonal' })) as { codigo: string }[]
|
|
expect(diagonal.map((u) => u.codigo)).toEqual(['DIA-101'])
|
|
|
|
// por modelo (texto contenido, case-insensitive)
|
|
const loft = (await e.buscar_unidad({ modelo: 'loft' })) as { codigo: string }[]
|
|
expect(loft.map((u) => u.codigo)).toEqual(['DIA-101'])
|
|
|
|
// por precio máximo (sin acabados)
|
|
const baratas = (await e.buscar_unidad({ precioMax: 2_500_000 })) as { precio: number }[]
|
|
expect(baratas.length).toBeGreaterThan(0)
|
|
expect(baratas.every((u) => u.precio <= 2_500_000)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('ejecutarToolCall', () => {
|
|
const ejecutores = {
|
|
async cotizar_unidad(args: ArgsCotizarUnidad) {
|
|
return { ok: true, args }
|
|
},
|
|
async buscar_unidad(_args: ArgsBuscarUnidad) {
|
|
throw new Error('proveedor 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 () => {
|
|
const r = await ejecutarToolCall(
|
|
{ id: 't1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6"}' },
|
|
ejecutores,
|
|
)
|
|
expect(JSON.parse(r)).toEqual({ ok: true, args: { codigo: 'SOLANA-6' } })
|
|
})
|
|
|
|
it('argumentos inválidos, herramienta desconocida o error del ejecutor van como JSON de error', async () => {
|
|
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'cotizar_unidad', argumentosJson: '{mal' }, ejecutores)))
|
|
.toHaveProperty('error')
|
|
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'borrar_todo', argumentosJson: '{}' }, ejecutores)))
|
|
.toEqual({ error: 'Herramienta desconocida: borrar_todo' })
|
|
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'buscar_unidad', argumentosJson: '{}' }, ejecutores)))
|
|
.toEqual({ error: 'proveedor caído' })
|
|
})
|
|
})
|
|
|
|
// ---------- 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 */
|
|
function clienteConTools(respuestas: RespuestaCruda[]) {
|
|
const llamadas: { mensajes: MensajeApi[]; herramientas: HerramientaDef[] }[] = []
|
|
const client: NanClient & { llamadas: typeof llamadas } = {
|
|
llamadas,
|
|
async embed(textos: string[]) {
|
|
return textos.map((t) => [t.length, 1, 1])
|
|
},
|
|
async chat() {
|
|
return 'chat plano'
|
|
},
|
|
async chatConTools(mensajes, herramientas) {
|
|
// snapshot: el array de mensajes se muta entre rondas
|
|
llamadas.push({ mensajes: mensajes.map((m) => ({ ...m })), herramientas })
|
|
return respuestas[llamadas.length - 1] ?? { contenido: 'fin', toolCalls: [] }
|
|
},
|
|
}
|
|
return client
|
|
}
|
|
|
|
function mocksPrismaChat() {
|
|
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-tools', createdAt: new Date() })
|
|
prismaMock.aIChunk.findMany.mockResolvedValue([])
|
|
prismaMock.aIMensaje.findMany.mockResolvedValue([])
|
|
prismaMock.aIMensaje.create.mockResolvedValue({})
|
|
}
|
|
|
|
describe('chat con herramientas (tool calling)', () => {
|
|
it('ejecuta el tool pedido y hace la segunda llamada con el resultado', async () => {
|
|
mocksPrismaChat()
|
|
const client = clienteConTools([
|
|
{
|
|
contenido: null,
|
|
toolCalls: [{ id: 'call-1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6","tipoPropuesta":"BANCARIO"}' }],
|
|
},
|
|
{ contenido: 'La SOLANA-6 queda con enganche de $80,000…', toolCalls: [] },
|
|
])
|
|
const argsRecibidos: ArgsCotizarUnidad[] = []
|
|
const ejecutores = {
|
|
async cotizar_unidad(args: ArgsCotizarUnidad) {
|
|
argsRecibidos.push(args)
|
|
return { enganche: 80000, mensualidad: 8333.33 }
|
|
},
|
|
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 })
|
|
|
|
expect(r.respuesta).toContain('80,000')
|
|
expect(client.llamadas).toHaveLength(2)
|
|
// primera llamada: con las herramientas registradas
|
|
expect(client.llamadas[0]!.herramientas).toEqual(HERRAMIENTAS)
|
|
// el ejecutor recibió los argumentos parseados
|
|
expect(argsRecibidos).toEqual([{ codigo: 'SOLANA-6', tipoPropuesta: 'BANCARIO' }])
|
|
// segunda llamada: historial con el tool_call del asistente y la respuesta del tool
|
|
const msgs2 = client.llamadas[1]!.mensajes
|
|
const asistente = msgs2.find((m) => m.role === 'assistant' && m.tool_calls)
|
|
expect(asistente?.tool_calls?.[0]).toMatchObject({ id: 'call-1', function: { name: 'cotizar_unidad' } })
|
|
const toolMsg = msgs2.find((m) => m.role === 'tool')
|
|
expect(toolMsg?.tool_call_id).toBe('call-1')
|
|
expect(JSON.parse(toolMsg!.content!)).toMatchObject({ enganche: 80000 })
|
|
// se persisten solo user/assistant (los tools no van a la BD)
|
|
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
|
|
expect(roles).toEqual(['user', 'assistant'])
|
|
})
|
|
|
|
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
|
|
mocksPrismaChat()
|
|
const toolCall = (id: string): RespuestaCruda => ({
|
|
contenido: null,
|
|
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
|
|
})
|
|
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }])
|
|
const ejecutores = {
|
|
async cotizar_unidad() {
|
|
return {}
|
|
},
|
|
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 })
|
|
|
|
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
|
|
expect(client.llamadas[2]!.herramientas).toEqual([])
|
|
expect(r.respuesta).toBe('cierre forzado')
|
|
})
|
|
|
|
it('respuesta directa sin tools: una sola llamada', async () => {
|
|
mocksPrismaChat()
|
|
const client = clienteConTools([{ contenido: 'Respuesta directa.', toolCalls: [] }])
|
|
const r = await chat(null, 'hola', client, {
|
|
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.')
|
|
})
|
|
})
|
|
|
|
// ---------- Memoria por usuario ----------
|
|
|
|
describe('chat: memoria por usuario', () => {
|
|
it('la conversación nueva queda ligada al usuarioId del token', async () => {
|
|
mocksPrismaChat()
|
|
const client = clienteFalso()
|
|
|
|
await chat(null, 'hola', client, { usuarioId: 'user-123' })
|
|
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: 'user-123' } })
|
|
|
|
prismaMock.aIConversacion.create.mockClear()
|
|
await chat(null, 'hola', client)
|
|
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: null } })
|
|
})
|
|
})
|