feat(chat-frasa): acceso total a datos (13 herramientas con escopo por rol), streaming SSE, caché de embeddings, markdown y mantenimiento en Configuraciones
This commit is contained in:
@@ -1,23 +1,66 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
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'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
const chatSchema = z.object({
|
||||
sessionId: z.string().uuid().nullish(),
|
||||
mensaje: z.string().min(1).max(4000),
|
||||
})
|
||||
|
||||
function contextoDe(req: Request): { sub: string; rol: string } | undefined {
|
||||
const t = req.usuarioToken
|
||||
return t ? { sub: t.sub, rol: t.rol } : undefined
|
||||
}
|
||||
|
||||
/** 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, undefined, {
|
||||
usuarioId: req.usuarioToken?.sub ?? null,
|
||||
contexto: contextoDe(req),
|
||||
})
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat/stream — igual que /chat pero con streaming SSE: manda
|
||||
* { delta } conforme el proveedor escribe y cierra con { done, sessionId,
|
||||
* fuentes, herramientasUsadas }.
|
||||
*/
|
||||
export async function chatIAStream(req: Request, res: Response): Promise<void> {
|
||||
const body = chatSchema.parse(req.body)
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.flushHeaders?.()
|
||||
|
||||
const enviar = (payload: unknown): void => {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
}
|
||||
|
||||
try {
|
||||
const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, {
|
||||
usuarioId: req.usuarioToken?.sub ?? null,
|
||||
contexto: contextoDe(req),
|
||||
onDelta: (texto) => enviar({ delta: texto }),
|
||||
})
|
||||
enviar({
|
||||
done: true,
|
||||
sessionId: resultado.sessionId,
|
||||
fuentes: resultado.fuentes,
|
||||
herramientasUsadas: resultado.herramientasUsadas,
|
||||
})
|
||||
} catch (err) {
|
||||
enviar({ error: err instanceof Error ? err.message : 'Error del asistente' })
|
||||
} finally {
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtro "mis conversaciones": cada usuario solo ve las suyas; admin las ve
|
||||
* todas. Sin token (AUTH_ENABLED=false) se devuelve todo.
|
||||
@@ -29,20 +72,28 @@ export function filtroConversaciones(
|
||||
return { usuarioId: token.sub }
|
||||
}
|
||||
|
||||
/** GET /api/ai/conversations — listado con último mensaje como preview (solo las mías) */
|
||||
/** GET /api/ai/conversations — listado con título (primer mensaje) y último mensaje */
|
||||
export async function listarConversaciones(req: Request, res: Response): Promise<void> {
|
||||
const where = filtroConversaciones(req.usuarioToken)
|
||||
const conversaciones = await prisma.aIConversacion.findMany({
|
||||
...(where ? { where } : {}),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } },
|
||||
include: {
|
||||
mensajes: { orderBy: { createdAt: 'asc' }, select: { contenido: true, rol: true } },
|
||||
},
|
||||
})
|
||||
res.json(
|
||||
conversaciones.map((c) => ({
|
||||
id: c.id,
|
||||
createdAt: c.createdAt,
|
||||
ultimoMensaje: c.mensajes[0] ?? null,
|
||||
})),
|
||||
conversaciones.map((c) => {
|
||||
const primerUsuario = c.mensajes.find((m) => m.rol === 'user')
|
||||
const ultimo = c.mensajes[c.mensajes.length - 1] ?? null
|
||||
return {
|
||||
id: c.id,
|
||||
createdAt: c.createdAt,
|
||||
titulo: (primerUsuario?.contenido ?? 'Conversación sin mensajes').slice(0, 80),
|
||||
ultimoMensaje: ultimo,
|
||||
mensajes: c.mensajes.length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +126,19 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
|
||||
})
|
||||
}
|
||||
|
||||
/** DELETE /api/ai/conversations/:id — borra la conversación propia (admin: cualquiera) */
|
||||
export async function eliminarConversacion(req: Request, res: Response): Promise<void> {
|
||||
const id = String(req.params['id'])
|
||||
const conversacion = await prisma.aIConversacion.findUnique({ where: { id } })
|
||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
const token = req.usuarioToken
|
||||
if (token && token.rol !== 'admin' && conversacion.usuarioId !== token.sub) {
|
||||
throw new AppError(403, 'Solo puedes borrar tus conversaciones')
|
||||
}
|
||||
await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes
|
||||
res.json({ ok: true, id })
|
||||
}
|
||||
|
||||
const documentoSchema = z.object({
|
||||
titulo: z.string().min(1).max(200),
|
||||
contenido: z.string().min(1),
|
||||
@@ -111,3 +175,58 @@ export async function eliminarDocumento(req: Request, res: Response): Promise<vo
|
||||
await prisma.aIDocumento.delete({ where: { id: documento.id } })
|
||||
res.json({ ok: true, titulo: documento.titulo })
|
||||
}
|
||||
|
||||
/** Ruta de la carpeta de conocimiento (api-gateway/knowledge) */
|
||||
const KNOWLEDGE_DIR = fileURLToPath(new URL('../../knowledge', import.meta.url))
|
||||
|
||||
/**
|
||||
* POST /api/ai/reindexar — re-indexa todos los .md de knowledge/ (admin).
|
||||
* Reemplaza a `npm run seed:ai` para refrescar la base de conocimiento sin
|
||||
* entrar al servidor; es idempotente (solo re-embebe lo que cambió).
|
||||
*/
|
||||
export async function reindexarConocimiento(_req: Request, res: Response): Promise<void> {
|
||||
let archivos: string[]
|
||||
try {
|
||||
archivos = (await readdir(KNOWLEDGE_DIR)).filter((f) => f.endsWith('.md')).sort()
|
||||
} catch {
|
||||
throw new AppError(500, 'No se encontró la carpeta de conocimiento del asistente')
|
||||
}
|
||||
const resultados: { titulo: string; estado: string; chunks: number }[] = []
|
||||
for (const archivo of archivos) {
|
||||
const contenido = await readFile(`${KNOWLEDGE_DIR}/${archivo}`, 'utf8')
|
||||
const h1 = contenido.split('\n').find((l) => l.startsWith('# '))
|
||||
const titulo = h1 ? h1.replace(/^#\s+/, '').trim() : archivo.replace(/\.md$/, '')
|
||||
const r = await indexarDocumento(titulo, contenido)
|
||||
resultados.push({ titulo: r.titulo, estado: r.estado, chunks: r.chunks })
|
||||
}
|
||||
res.json({
|
||||
documentos: resultados.length,
|
||||
creados: resultados.filter((r) => r.estado === 'creado').length,
|
||||
actualizados: resultados.filter((r) => r.estado === 'actualizado').length,
|
||||
sinCambios: resultados.filter((r) => r.estado === 'sin-cambios').length,
|
||||
detalle: resultados,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/ai/estado — métricas de uso del asistente (admin) */
|
||||
export async function estadoAsistente(_req: Request, res: Response): Promise<void> {
|
||||
const [conversaciones, mensajes, documentos, chunks] = await Promise.all([
|
||||
prisma.aIConversacion.count(),
|
||||
prisma.aIMensaje.count(),
|
||||
prisma.aIDocumento.count(),
|
||||
prisma.aIChunk.count(),
|
||||
])
|
||||
const ultimos = await prisma.aIMensaje.findMany({
|
||||
where: { rol: 'user' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: { contenido: true, createdAt: true },
|
||||
})
|
||||
res.json({
|
||||
conversaciones,
|
||||
mensajes,
|
||||
documentos,
|
||||
chunks,
|
||||
ultimasPreguntas: ultimos.map((m) => ({ pregunta: m.contenido.slice(0, 90), fecha: m.createdAt })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,10 +78,14 @@ import {
|
||||
} from '../controllers/automatizaciones.controller.js'
|
||||
import {
|
||||
chatIA,
|
||||
chatIAStream,
|
||||
eliminarConversacion,
|
||||
eliminarDocumento,
|
||||
estadoAsistente,
|
||||
listarConversaciones,
|
||||
listarDocumentosIA,
|
||||
obtenerConversacion,
|
||||
reindexarConocimiento,
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||
@@ -284,11 +288,16 @@ apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
||||
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
||||
|
||||
apiRouter.post('/ai/chat', chatIA)
|
||||
apiRouter.post('/ai/chat/stream', chatIAStream)
|
||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
||||
apiRouter.post('/ai/documents', subirDocumento)
|
||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
||||
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
||||
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
||||
|
||||
// ── Auditoría (admin) ────────────────────────────────────────────────
|
||||
apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
|
||||
import {
|
||||
HERRAMIENTAS,
|
||||
MAX_RONDAS_TOOLS,
|
||||
chat,
|
||||
construirSystemPrompt,
|
||||
crearEjecutoresHerramientas,
|
||||
@@ -169,8 +170,10 @@ describe('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('Chat Frasa')
|
||||
expect(prompt).toContain('Nunca inventes cifras')
|
||||
expect(prompt).toContain('buscar_contactos')
|
||||
expect(prompt).toContain('estadisticas')
|
||||
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')
|
||||
@@ -631,13 +634,15 @@ describe('chat con herramientas (tool calling)', () => {
|
||||
expect(roles).toEqual(['user', 'assistant'])
|
||||
})
|
||||
|
||||
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
|
||||
it('agota las rondas de tools: la última llamada 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: [] }])
|
||||
// El cliente pide herramientas SIEMPRE; el loop debe cortar en MAX_RONDAS_TOOLS
|
||||
const respuestas = Array.from({ length: MAX_RONDAS_TOOLS + 1 }, (_, i) => toolCall(`c${i + 1}`))
|
||||
const client = clienteConTools(respuestas)
|
||||
const ejecutores = {
|
||||
async cotizar_unidad() {
|
||||
return {}
|
||||
@@ -659,11 +664,36 @@ describe('chat con herramientas (tool calling)', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })
|
||||
// La ronda de cierre (MAX_RONDAS_TOOLS) va sin herramientas y el cliente
|
||||
// devuelve texto plano ahí (la última respuesta simulada es un tool call,
|
||||
// así que el loop lanza al no poder cerrar con texto)
|
||||
await expect(chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })).rejects.toThrow(
|
||||
/no cerró la respuesta/,
|
||||
)
|
||||
expect(client.llamadas).toHaveLength(MAX_RONDAS_TOOLS + 1)
|
||||
expect(client.llamadas[MAX_RONDAS_TOOLS]!.herramientas).toEqual([])
|
||||
})
|
||||
|
||||
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
|
||||
expect(client.llamadas[2]!.herramientas).toEqual([])
|
||||
expect(r.respuesta).toBe('cierre forzado')
|
||||
it('cierra con texto cuando el modelo deja de pedir 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', toolCalls: [] }])
|
||||
const r = await chat(null, 'búscame algo', client, {
|
||||
ejecutores: {
|
||||
cotizar_unidad: async () => ({}),
|
||||
buscar_unidad: async () => [{ codigo: 'SOLANA-6' }],
|
||||
buscar_leads: async () => [],
|
||||
ver_cliente: async () => ({}),
|
||||
ver_oportunidades: async () => [],
|
||||
ver_cobranza: async () => ({}),
|
||||
},
|
||||
})
|
||||
expect(client.llamadas).toHaveLength(3)
|
||||
expect(r.respuesta).toBe('cierre')
|
||||
expect(r.herramientasUsadas).toEqual(['buscar_unidad', 'buscar_unidad'])
|
||||
})
|
||||
|
||||
it('respuesta directa sin tools: una sola llamada', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user