feat(ai): agente IA interno con RAG sobre NAN + app web en /ai
- API: /api/ai/chat, /conversations, /documents con embeddings qwen3-embedding, coseno en SQLite, dedup por hash, historial de conversaciones - Base de conocimiento semilla (6 docs, 36 chunks): catálogo DIAGONAL, propuestas del cotizador, créditos, proceso de venta, FAQ, privacidad - App agente-ia (Vite/React/Tailwind, StudioBlank) servida en /ai/ vía nginx - 21 tests nuevos (91/91 verdes)
This commit is contained in:
279
api-gateway/src/services/ai.service.test.ts
Normal file
279
api-gateway/src/services/ai.service.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NanClient } from './ai.service.js'
|
||||
import {
|
||||
chat,
|
||||
construirSystemPrompt,
|
||||
decidirIndexacion,
|
||||
dedupeFuentes,
|
||||
fragmentar,
|
||||
hashContenido,
|
||||
indexarDocumento,
|
||||
rankearChunks,
|
||||
similitudCoseno,
|
||||
} from './ai.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(),
|
||||
},
|
||||
}))
|
||||
|
||||
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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user