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:
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user