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:
@@ -38,6 +38,15 @@ export const config = {
|
||||
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
||||
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
||||
},
|
||||
ai: {
|
||||
// Proveedor NAN (OpenAI-compatible): chat + embeddings
|
||||
apiKey: env('NAN_API_KEY'),
|
||||
baseUrl: env('NAN_BASE_URL', 'https://api.nan.builders/v1'),
|
||||
chatModel: env('NAN_CHAT_MODEL', 'qwen3.6'),
|
||||
embedModel: env('NAN_EMBED_MODEL', 'qwen3-embedding'),
|
||||
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||
minScore: Number(env('AI_MIN_SCORE', '0.3')),
|
||||
},
|
||||
} as const
|
||||
|
||||
export type AppConfig = typeof config
|
||||
|
||||
98
api-gateway/src/controllers/ai.controller.ts
Normal file
98
api-gateway/src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
const chatSchema = z.object({
|
||||
sessionId: z.string().uuid().nullish(),
|
||||
mensaje: z.string().min(1).max(4000),
|
||||
})
|
||||
|
||||
/** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */
|
||||
export async function chatIA(req: Request, res: Response): Promise<void> {
|
||||
const body = chatSchema.parse(req.body)
|
||||
const resultado = await chat(body.sessionId ?? null, body.mensaje)
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
/** GET /api/ai/conversations — listado con último mensaje como preview */
|
||||
export async function listarConversaciones(_req: Request, res: Response): Promise<void> {
|
||||
const conversaciones = await prisma.aIConversacion.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } },
|
||||
})
|
||||
res.json(
|
||||
conversaciones.map((c) => ({
|
||||
id: c.id,
|
||||
createdAt: c.createdAt,
|
||||
ultimoMensaje: c.mensajes[0] ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function parseFuentes(fuentes: string | null): FuenteCitada[] {
|
||||
if (!fuentes) return []
|
||||
try {
|
||||
return JSON.parse(fuentes) as FuenteCitada[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/ai/conversations/:id — historial completo */
|
||||
export async function obtenerConversacion(req: Request, res: Response): Promise<void> {
|
||||
const conversacion = await prisma.aIConversacion.findUnique({
|
||||
where: { id: String(req.params['id']) },
|
||||
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
res.json({
|
||||
id: conversacion.id,
|
||||
createdAt: conversacion.createdAt,
|
||||
mensajes: conversacion.mensajes.map((m) => ({
|
||||
id: m.id,
|
||||
rol: m.rol,
|
||||
contenido: m.contenido,
|
||||
fuentes: parseFuentes(m.fuentes),
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const documentoSchema = z.object({
|
||||
titulo: z.string().min(1).max(200),
|
||||
contenido: z.string().min(1),
|
||||
})
|
||||
|
||||
/** POST /api/ai/documents — indexa o re-indexa (si cambió el hash) un documento */
|
||||
export async function subirDocumento(req: Request, res: Response): Promise<void> {
|
||||
const body = documentoSchema.parse(req.body)
|
||||
const resultado = await indexarDocumento(body.titulo, body.contenido)
|
||||
res.status(resultado.estado === 'creado' ? 201 : 200).json(resultado)
|
||||
}
|
||||
|
||||
/** GET /api/ai/documents — metadatos y conteo de chunks (sin contenido ni embeddings) */
|
||||
export async function listarDocumentosIA(_req: Request, res: Response): Promise<void> {
|
||||
const documentos = await prisma.aIDocumento.findMany({
|
||||
orderBy: { titulo: 'asc' },
|
||||
include: { chunks: { select: { id: true } } },
|
||||
})
|
||||
res.json(
|
||||
documentos.map((d) => ({
|
||||
id: d.id,
|
||||
titulo: d.titulo,
|
||||
hash: d.hash,
|
||||
chunks: d.chunks.length,
|
||||
createdAt: d.createdAt,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/** DELETE /api/ai/documents/:id — borra el documento y sus chunks (cascade) */
|
||||
export async function eliminarDocumento(req: Request, res: Response): Promise<void> {
|
||||
const documento = await prisma.aIDocumento.findUnique({ where: { id: String(req.params['id']) } })
|
||||
if (!documento) throw new NotFoundError('Documento no encontrado')
|
||||
await prisma.aIDocumento.delete({ where: { id: documento.id } })
|
||||
res.json({ ok: true, titulo: documento.titulo })
|
||||
}
|
||||
@@ -28,6 +28,14 @@ import {
|
||||
estadoAutomatizaciones,
|
||||
previewInactividad,
|
||||
} from '../controllers/automatizaciones.controller.js'
|
||||
import {
|
||||
chatIA,
|
||||
eliminarDocumento,
|
||||
listarConversaciones,
|
||||
listarDocumentosIA,
|
||||
obtenerConversacion,
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -58,3 +66,10 @@ apiRouter.post('/webhooks/monday', webhookMonday)
|
||||
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
||||
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
||||
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
||||
|
||||
apiRouter.post('/ai/chat', chatIA)
|
||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||
apiRouter.post('/ai/documents', subirDocumento)
|
||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||
|
||||
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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
355
api-gateway/src/services/ai.service.ts
Normal file
355
api-gateway/src/services/ai.service.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from './prisma.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
|
||||
|
||||
export interface MensajeChat {
|
||||
rol: 'system' | 'user' | 'assistant'
|
||||
contenido: string
|
||||
}
|
||||
|
||||
export interface NanClient {
|
||||
chat(mensajes: MensajeChat[]): Promise<string>
|
||||
embed(textos: string[]): Promise<number[][]>
|
||||
}
|
||||
|
||||
interface NanOpciones {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
chatModel: string
|
||||
embedModel: string
|
||||
}
|
||||
|
||||
async function postJson(url: string, apiKey: string, body: unknown): Promise<unknown> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detalle = await res.text().catch(() => '')
|
||||
throw new AppError(502, `Proveedor IA respondió ${res.status}: ${detalle.slice(0, 200)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function crearNanClient(opciones: NanOpciones): NanClient {
|
||||
return {
|
||||
async chat(mensajes) {
|
||||
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, {
|
||||
model: opciones.chatModel,
|
||||
messages: mensajes.map((m) => ({ role: m.rol, content: m.contenido })),
|
||||
temperature: 0.2,
|
||||
})) as { choices?: { message?: { content?: string } }[] }
|
||||
const contenido = data.choices?.[0]?.message?.content
|
||||
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
|
||||
return contenido
|
||||
},
|
||||
async embed(textos) {
|
||||
if (textos.length === 0) return []
|
||||
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
|
||||
model: opciones.embedModel,
|
||||
input: textos,
|
||||
})) as { data?: { embedding?: number[] }[] }
|
||||
const embeddings = (data.data ?? []).map((d) => d.embedding ?? [])
|
||||
if (embeddings.length !== textos.length || embeddings.some((e) => e.length === 0)) {
|
||||
throw new AppError(502, 'El proveedor IA no devolvió los embeddings esperados')
|
||||
}
|
||||
return embeddings
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton perezoso: no exige NAN_API_KEY en procesos que no usan IA
|
||||
let clientePorDefecto: NanClient | null = null
|
||||
function clienteNan(): NanClient {
|
||||
clientePorDefecto ??= crearNanClient({
|
||||
apiKey: config.ai.apiKey,
|
||||
baseUrl: config.ai.baseUrl,
|
||||
chatModel: config.ai.chatModel,
|
||||
embedModel: config.ai.embedModel,
|
||||
})
|
||||
return clientePorDefecto
|
||||
}
|
||||
|
||||
// ---------- Chunking (por párrafos, con solape) ----------
|
||||
|
||||
const TAMANO_CHUNK = 700
|
||||
const SOLAPE_CHUNK = 120
|
||||
|
||||
/** Corta un texto largo en pedazos de ≤ tamanoMax con solape de caracteres */
|
||||
function cortarDuro(texto: string, tamanoMax: number, solape: number): string[] {
|
||||
const pedazos: string[] = []
|
||||
let i = 0
|
||||
while (i < texto.length) {
|
||||
pedazos.push(texto.slice(i, i + tamanoMax))
|
||||
i += tamanoMax - solape
|
||||
}
|
||||
return pedazos
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragmenta un documento en chunks de ~tamanoMax caracteres respetando los
|
||||
* párrafos (separados por líneas en blanco); un párrafo que excede el tamaño
|
||||
* se corta duro. Cada chunk nuevo arranca con la cola del anterior (solape).
|
||||
*/
|
||||
export function fragmentar(texto: string, tamanoMax = TAMANO_CHUNK, solape = SOLAPE_CHUNK): string[] {
|
||||
const parrafos = texto
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const chunks: string[] = []
|
||||
let actual = ''
|
||||
const cerrar = () => {
|
||||
if (actual.trim().length > 0) chunks.push(actual.trim())
|
||||
actual = actual.slice(-solape)
|
||||
}
|
||||
for (const parrafo of parrafos) {
|
||||
if (parrafo.length > tamanoMax) {
|
||||
cerrar()
|
||||
chunks.push(...cortarDuro(parrafo, tamanoMax, solape))
|
||||
actual = (chunks[chunks.length - 1] ?? '').slice(-solape)
|
||||
continue
|
||||
}
|
||||
const candidato = actual.length > 0 ? `${actual}\n\n${parrafo}` : parrafo
|
||||
if (candidato.length > tamanoMax && actual.trim().length > 0) {
|
||||
cerrar()
|
||||
actual = `${actual} ${parrafo}`
|
||||
} else {
|
||||
actual = candidato
|
||||
}
|
||||
}
|
||||
if (actual.trim().length > 0) chunks.push(actual.trim())
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ---------- Similitud coseno y ranking ----------
|
||||
|
||||
export function similitudCoseno(a: number[], b: number[]): number {
|
||||
let dot = 0
|
||||
let normA = 0
|
||||
let normB = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const va = a[i] ?? 0
|
||||
const vb = b[i] ?? 0
|
||||
dot += va * vb
|
||||
normA += va * va
|
||||
normB += vb * vb
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||
}
|
||||
|
||||
export interface ChunkRecuperado {
|
||||
titulo: string
|
||||
texto: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface ChunkIndexado {
|
||||
titulo: string
|
||||
texto: string
|
||||
embedding: number[]
|
||||
}
|
||||
|
||||
/** Top-k por coseno, filtrado por umbral mínimo, ordenado de mayor a menor score */
|
||||
export function rankearChunks(
|
||||
chunks: ChunkIndexado[],
|
||||
consulta: number[],
|
||||
topK = 4,
|
||||
minScore = config.ai.minScore,
|
||||
): ChunkRecuperado[] {
|
||||
return chunks
|
||||
.map((c) => ({ titulo: c.titulo, texto: c.texto, score: similitudCoseno(consulta, c.embedding) }))
|
||||
.filter((c) => c.score >= minScore)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK)
|
||||
}
|
||||
|
||||
// ---------- Prompt ----------
|
||||
|
||||
export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
|
||||
const bloques = contexto
|
||||
.map((c, i) => `[${i + 1}] Fuente: ${c.titulo}\n${c.texto}`)
|
||||
.join('\n\n---\n\n')
|
||||
return [
|
||||
'Eres el asistente interno de Frasa Desarrollos (desarrolladora de vivienda en Tijuana, B.C.).',
|
||||
'Ayudas al equipo de ventas, cobranza y administración respondiendo preguntas sobre el catálogo,',
|
||||
'las propuestas comerciales del cotizador, los procesos internos y las políticas de la empresa.',
|
||||
'',
|
||||
'REGLAS:',
|
||||
'- Responde SOLO con base en el CONTEXTO proporcionado abajo. No inventes datos, precios ni cifras.',
|
||||
'- Si el contexto no es suficiente para responder, dilo claramente y sugiere a qué área acudir',
|
||||
' (Ventas, Crédito y Finanzas, Administración o Jurídico).',
|
||||
'- Responde en español, de forma clara y concisa.',
|
||||
'- Al final de tu respuesta cita las fuentes usadas con el formato: "Fuentes: <título 1>, <título 2>".',
|
||||
'',
|
||||
contexto.length > 0
|
||||
? `CONTEXTO:\n\n${bloques}`
|
||||
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------- Indexación de documentos (dedup por hash) ----------
|
||||
|
||||
export function hashContenido(contenido: string): string {
|
||||
return crypto.createHash('sha256').update(contenido, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export type AccionIndexacion = 'crear' | 'actualizar' | 'omitir'
|
||||
|
||||
/** Decide qué hacer con un documento según su hash: idempotente si no cambió */
|
||||
export function decidirIndexacion(
|
||||
existente: { hash: string } | null,
|
||||
hashNuevo: string,
|
||||
): AccionIndexacion {
|
||||
if (!existente) return 'crear'
|
||||
return existente.hash === hashNuevo ? 'omitir' : 'actualizar'
|
||||
}
|
||||
|
||||
export interface ResultadoIndexacion {
|
||||
titulo: string
|
||||
estado: 'creado' | 'actualizado' | 'sin-cambios'
|
||||
chunks: number
|
||||
}
|
||||
|
||||
/** Indexa (o re-indexa si cambió el contenido) un documento de la base de conocimiento */
|
||||
export async function indexarDocumento(
|
||||
titulo: string,
|
||||
contenido: string,
|
||||
client: NanClient = clienteNan(),
|
||||
): Promise<ResultadoIndexacion> {
|
||||
const hash = hashContenido(contenido)
|
||||
const existente = await prisma.aIDocumento.findUnique({
|
||||
where: { titulo },
|
||||
include: { chunks: { select: { id: true } } },
|
||||
})
|
||||
const accion = decidirIndexacion(existente, hash)
|
||||
if (accion === 'omitir' && existente) {
|
||||
return { titulo, estado: 'sin-cambios', chunks: existente.chunks.length }
|
||||
}
|
||||
|
||||
const textos = fragmentar(contenido)
|
||||
const embeddings = await client.embed(textos)
|
||||
|
||||
if (accion === 'actualizar' && existente) {
|
||||
await prisma.aIChunk.deleteMany({ where: { documentoId: existente.id } })
|
||||
await prisma.aIDocumento.update({
|
||||
where: { id: existente.id },
|
||||
data: {
|
||||
contenido,
|
||||
hash,
|
||||
chunks: {
|
||||
create: textos.map((texto, orden) => ({
|
||||
texto,
|
||||
orden,
|
||||
embedding: JSON.stringify(embeddings[orden] ?? []),
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
return { titulo, estado: 'actualizado', chunks: textos.length }
|
||||
}
|
||||
|
||||
await prisma.aIDocumento.create({
|
||||
data: {
|
||||
titulo,
|
||||
contenido,
|
||||
hash,
|
||||
chunks: {
|
||||
create: textos.map((texto, orden) => ({
|
||||
texto,
|
||||
orden,
|
||||
embedding: JSON.stringify(embeddings[orden] ?? []),
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
return { titulo, estado: 'creado', chunks: textos.length }
|
||||
}
|
||||
|
||||
// ---------- Chat RAG ----------
|
||||
|
||||
const HISTORIAL_MAX = 10
|
||||
const TOP_K = 4
|
||||
|
||||
export interface FuenteCitada {
|
||||
titulo: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface RespuestaChat {
|
||||
sessionId: string
|
||||
respuesta: string
|
||||
fuentes: FuenteCitada[]
|
||||
}
|
||||
|
||||
/** Fuentes únicas por título conservando el mejor score */
|
||||
export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
|
||||
const porTitulo = new Map<string, number>()
|
||||
for (const c of chunks) {
|
||||
porTitulo.set(c.titulo, Math.max(porTitulo.get(c.titulo) ?? 0, c.score))
|
||||
}
|
||||
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
|
||||
}
|
||||
|
||||
export async function chat(
|
||||
sessionId: string | null,
|
||||
mensaje: string,
|
||||
client: NanClient = clienteNan(),
|
||||
): Promise<RespuestaChat> {
|
||||
let conversacion = sessionId
|
||||
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
||||
: null
|
||||
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
conversacion ??= await prisma.aIConversacion.create({ data: {} })
|
||||
|
||||
await prisma.aIMensaje.create({
|
||||
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
|
||||
})
|
||||
|
||||
// Recuperación: embedding de la consulta contra todos los chunks (base chica, coseno en JS)
|
||||
const [consultaEmb] = await client.embed([mensaje])
|
||||
const chunksDb = await prisma.aIChunk.findMany({
|
||||
include: { documento: { select: { titulo: true } } },
|
||||
orderBy: [{ documentoId: 'asc' }, { orden: 'asc' }],
|
||||
})
|
||||
const top = consultaEmb
|
||||
? rankearChunks(
|
||||
chunksDb.map((c) => ({
|
||||
titulo: c.documento.titulo,
|
||||
texto: c.texto,
|
||||
embedding: JSON.parse(c.embedding) as number[],
|
||||
})),
|
||||
consultaEmb,
|
||||
TOP_K,
|
||||
)
|
||||
: []
|
||||
|
||||
// Historial: últimos N mensajes (incluye el que acabamos de persistir)
|
||||
const historial = await prisma.aIMensaje.findMany({
|
||||
where: { conversacionId: conversacion.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: -HISTORIAL_MAX,
|
||||
})
|
||||
const mensajes: MensajeChat[] = [
|
||||
{ rol: 'system', contenido: construirSystemPrompt(top) },
|
||||
...historial.map((m) => ({ rol: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), contenido: m.contenido })),
|
||||
]
|
||||
|
||||
const respuesta = await client.chat(mensajes)
|
||||
const fuentes = dedupeFuentes(top)
|
||||
|
||||
await prisma.aIMensaje.create({
|
||||
data: {
|
||||
conversacionId: conversacion.id,
|
||||
rol: 'assistant',
|
||||
contenido: respuesta,
|
||||
fuentes: JSON.stringify(fuentes),
|
||||
},
|
||||
})
|
||||
|
||||
return { sessionId: conversacion.id, respuesta, fuentes }
|
||||
}
|
||||
Reference in New Issue
Block a user