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