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:
2026-08-22 03:04:47 +00:00
parent 8407fc24c3
commit 05c44478ec
32 changed files with 5448 additions and 0 deletions

View 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 })
}