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:
2026-09-17 08:41:21 +00:00
parent 278a79c984
commit 9643f866cc
17 changed files with 3133 additions and 177 deletions

View File

@@ -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 })),
})
}