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,22 @@
# Aviso de privacidad y política antilavado (alto nivel)
## Aviso de privacidad
- Responsable del tratamiento de datos personales: **Frasa Desarrollos S.A.P.I. de C.V.**, con domicilio en Blvd. Fundadores No. 3078-E, Colonia Juárez, Tijuana, Baja California.
- Marco legal: Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) y su reglamento.
- Los datos personales y sensibles del cliente se tratan como información confidencial y solo se comparten con empresas del grupo Frasa Desarrollos, o con autoridades cuando así lo requiera la ley.
- Finalidades: gestiones para obtención de créditos, actualización de bases de datos, relación comercial, contractual y de servicios, y finalidades análogas; también campañas de publicidad y mercadotecnia.
- El titular puede ejercer sus derechos **ARCO** (acceso, rectificación, cancelación y oposición) y revocar su consentimiento escribiendo a info@frasa.com.mx o llamando al (664) 676 8100.
- El aviso de privacidad integral está disponible en www.frasa.com.mx/aviso-de-privacidad/ y puede actualizarse; el cliente debe consultarlo periódicamente.
## Prevención de lavado de dinero (LFPIORPI)
La compraventa de inmuebles es una actividad vulnerable conforme a la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita (LFPIORPI). Implicaciones prácticas para el equipo:
- **Identificación del cliente**: toda operación requiere el formato de identificación de clientes (persona física), con identificación oficial, comprobante de domicilio, RFC y CURP.
- **Beneficiario controlador**: cuando el comprador no es quien ejerce el beneficio o control final de la operación, debe llenarse además el formato de identificación del beneficiario controlador.
- **Límite de efectivo**: los pagos en efectivo del cliente no pueden sumar más de **$600,000 MXN** (o su equivalente en dólares) durante toda la vigencia del contrato de compraventa, conforme al artículo 32 fracción I de la LFPIORPI. Los pagos que superen ese límite deben realizarse con cheque de la cuenta personal del cliente o transferencia electrónica; los pagos en efectivo por encima del límite no se reciben.
- Los formatos antilavado forman parte del expediente básico y son requisito para el pago de comisiones.
- Declarar con falsedad en estos formatos tiene consecuencias penales (artículo 62 de la LFPIORPI); el cliente firma bajo protesta de decir verdad.
Ante cualquier operación inusual (pagos en efectivo cercanos al límite, terceros que pagan por el cliente, prisa injustificada), avisa a administración antes de aceptar recursos.

View File

@@ -0,0 +1,42 @@
# Catálogo de unidades — Desarrollo DIAGONAL
DIAGONAL es un desarrollo de departamentos de Frasa Desarrollos en Tijuana, B.C. El inventario actual consta de 38 unidades, todas con status DISPONIBLE, organizadas en tres niveles (series U-1xx, U-2xx y U-3xx). Las entregas están programadas entre octubre y noviembre de 2027.
## Modelos
- **1R** — 1 recámara. Superficie de construcción: 47.87 m². Estacionamiento: 14.31 m².
- **2RA** — 2 recámaras. Superficie de construcción: 69.485 m². Estacionamiento: 28.62 m².
- **2RB** — 2 recámaras. Superficie de construcción: 68.757 m². Estacionamiento: 28.62 m².
- **2RC** — 2 recámaras. Superficie de construcción: 72.328 m². Estacionamiento: 28.62 m².
El precio "con acabados" incluye cocina y clóset; el precio "sin acabados" es el precio base de la unidad.
## Precios de lista (MXN)
### Nivel 1 (U-101 a U-114) — entrega a partir del 8 de octubre de 2027
Las unidades de nivel 1 incluyen superficie excedente (terraza/balcón) de entre 9.13 m² y 21.34 m² según la unidad.
- 1R: $3,107,108.39 sin acabados / $3,269,000 con acabados.
- 2RA: $4,415,000 sin acabados / $4,579,000 con acabados.
### Nivel 2 (U-201 a U-214) — entregas del 15 de octubre al 12 de noviembre de 2027
Sin superficie excedente.
- 1R: $2,850,000 sin acabados / $2,950,000 con acabados.
- 2RA: $3,960,000 sin acabados / $4,127,000 con acabados.
### Nivel 3 (U-301 a U-310) — entregas del 12 al 26 de noviembre de 2027
Sin superficie excedente.
- 2RA: $4,150,000 sin acabados / $4,315,000 con acabados.
- 2RB: $4,120,000 sin acabados / $4,280,000 con acabados.
- 2RC: $4,300,000 sin acabados / $4,450,000 con acabados.
## Notas comerciales
- Los precios son de lista y pueden cambiar; confirma siempre el precio vigente en el cotizador antes de presentar cifras al cliente.
- El apartado estándar es de $20,000 MXN y se descuenta del enganche en todas las propuestas.
- La disponibilidad se actualiza conforme se apartan unidades; verifica el status antes de cotizar.

View File

@@ -0,0 +1,39 @@
# FAQ para vendedores
## ¿Cuál es el apartado y cómo se aplica?
El apartado estándar es de $20,000 MXN. No es un cargo adicional: se descuenta del enganche en las 4 propuestas del cotizador.
## ¿Qué incluye el precio "con acabados"?
Incluye cocina y clóset. El precio "sin acabados" es el precio base de la unidad. La diferencia de precio entre ambos varía por modelo y nivel; consúltala en el catálogo del desarrollo.
## ¿Cuántas mensualidades se pagan en las propuestas con crédito?
Las mensualidades cubren el periodo de construcción: se calculan como los meses completos desde la fecha de la cotización hasta la fecha de entrega de la unidad. Por eso dos unidades con distinta fecha de entrega pueden tener distinto número de mensualidades aunque cuesten lo mismo.
## ¿Qué propuesta le conviene más al cliente?
Depende de su situación: Contado y Semi-contado tienen descuento por defecto (2% y 1% respectivamente) y menor total pagado, pero exigen mayor desembolso inicial. Bancario e Infonavit-Cofinavit requieren solo 2.5% de enganche sobre precio de lista y difieren la mayor parte al pago final contra entrega, que cubre el crédito. Usa el comparador del cotizador: destaca la propuesta con menor total pagado.
## ¿Los descuentos se pueden modificar?
Los valores por defecto son 0% (Bancario e Infonavit) y 2% / 1% (Contado / Semi-contado). Cualquier descuento adicional debe autorizarse con gerencia antes de presentarlo al cliente.
## ¿Qué pasa si el cliente se arrepiente después de apartar?
Tiene 15 días naturales para formalizar; si no lo hace, la unidad se libera. La devolución del apartado (o la resultante si hay penalización) se realiza en un máximo de 30 días naturales.
## ¿Cuándo se paga la comisión del asesor?
Cuando el expediente básico está completo y entregado a administración y finanzas: formatos generales y antilavado, contrato firmado en 3 tantos, carta compromiso, cotizador firmado, identificación oficial, comprobante de domicilio y copia de los recibos de pago. El plazo para entregarlo es de 48 horas después de realizada la venta.
## ¿El cliente puede pagar en efectivo?
Los pagos en efectivo no pueden sumar más de $600,000 MXN durante toda la vigencia del contrato (límite de la ley antilavado). Los montos superiores deben pagarse con cheque de la cuenta personal del cliente o transferencia electrónica.
## ¿Dónde dirijo dudas que este asistente no resuelva?
- Precios, disponibilidad y descuentos: gerencia de ventas.
- Créditos y comprobación de ingresos: ejecutivo de crédito in-house.
- Expedientes, contratos y escrituración: administración y jurídico.

View File

@@ -0,0 +1,32 @@
# Proceso de venta y apartado
## Apartado
1. El cliente elige unidad y se genera la cotización con las 4 propuestas (Bancario, Infonavit-Cofinavit, Contado y Semi-contado).
2. El cliente paga el apartado (por defecto $20,000 MXN) y firma la carta de apartado.
3. A partir del apartado, el cliente tiene un plazo de hasta **15 días naturales** para acudir a las oficinas, formalizar la compra y firmar el contrato, además de realizar los trámites correspondientes.
4. Si el cliente no continúa con el proceso dentro de ese plazo, el desarrollador puede disponer de la unidad apartada y comercializarla, sin responsabilidad alguna.
5. En caso de cancelación y solicitud de devolución (o de liberación de la unidad por vencimiento del plazo), la devolución de la cantidad entregada —o la resultante en caso de penalización— tiene un plazo máximo de **30 días naturales**.
## Formalización de la venta
La venta se considera realizada cuando se completa: pago de apartado + enganche + firma de contrato y carta compromiso. Una vez realizada, el asesor tiene un máximo de **48 horas** para entregar el expediente básico a administración y finanzas.
## Checklist del expediente básico
- Formatos generales y formatos de ley antilavado.
- Identificación oficial y su validación.
- CURP y RFC.
- Acta de nacimiento original (y acta de matrimonio, en su caso).
- Comprobante de domicilio.
- Cotizador firmado con el esquema de pago autorizado al cliente.
- Carta compromiso.
- Contrato original impreso y firmado en 3 tantos.
- Copia de los recibos de pago y sus comprobantes.
El expediente básico completo es requisito para hacer válido el **pago de la comisión** del asesor. El seguimiento a la integración del expediente hasta la escrituración es responsabilidad del asesor de ventas y de gerencia.
## Después de la venta
- Si el esquema incluye mensualidades durante construcción, se genera el plan de pagos y se da seguimiento en la app de cobranza.
- El saldo contra entrega (pago final) se liquida con el crédito hipotecario o con los recursos del cliente, según la propuesta elegida, al momento de la escrituración.

View File

@@ -0,0 +1,46 @@
# Las 4 propuestas del cotizador Frasa
El cotizador genera siempre 4 propuestas comerciales para la misma unidad. Todas parten del **precio de lista** (sin o con acabados, según lo que elija el cliente) y del **apartado** (por defecto $20,000 MXN), que ya se pagó y se descuenta del enganche.
Elementos comunes a las 4 propuestas:
- **Descuento**: fracción sobre el precio de lista. Valores por defecto: Bancario 0%, Infonavit-Cofinavit 0%, Contado 2%, Semi-contado 1%.
- **Monto de descuento** = precio de lista × descuento.
- **Precio final** = precio de lista − monto de descuento.
- **Número de mensualidades (N)**: meses completos desde hoy hasta la fecha de entrega de la unidad (mínimo 1). Las mensualidades se pagan durante la construcción.
## Propuesta 1 — Crédito Bancario
Para clientes que liquidarán con crédito hipotecario bancario a la entrega.
- Enganche = 2.5% del precio de **lista**, menos el apartado ya pagado.
- Pago final (contra entrega, cubierto por el crédito) = 90% del precio final con descuento.
- Mensualidades = precio final − apartado − enganche − pago final, dividido entre N.
## Propuesta 2 — Crédito Infonavit / Cofinavit
Para clientes que liquidarán con crédito Infonavit o Cofinavit (Infonavit + banco) a la entrega.
- Enganche = 2.5% del precio de **lista**, menos el apartado ya pagado.
- Pago final (contra entrega, cubierto por el crédito) = 95% del precio final con descuento.
- Mensualidades = precio de **lista** − pago final − apartado − enganche, dividido entre N. (Ojo: el Excel del cotizador usa aquí el precio de lista, no el precio final; así está validado y así se calcula.)
## Propuesta 3 — Contado
Sin mensualidades: el cliente paga casi todo de inmediato.
- Enganche = 90% del precio final con descuento, menos el apartado ya pagado.
- Pago final = 10% del precio final con descuento.
- Mensualidad: no aplica (N/A).
## Propuesta 4 — Semi-contado
Esquema 50 / 40 / 10 sobre el precio final con descuento.
- Enganche = 50% del precio final, menos el apartado ya pagado.
- Mensualidades = 40% del precio final, dividido entre N.
- Pago final = 10% del precio final.
## Comparación
El total pagado por el cliente en cualquier propuesta es: apartado + enganche + total de mensualidades + pago final. El cotizador destaca la propuesta con menor total pagado; en empate gana la primera (Bancario).

View File

@@ -0,0 +1,37 @@
# Requisitos de créditos hipotecarios (orientativo)
Contenido general de México, con fines orientativos para el equipo de ventas. Los requisitos exactos los define cada institución y cambian con el tiempo; confirma siempre con el ejecutivo de crédito in-house de Frasa antes de prometer algo al cliente.
## Crédito Infonavit
- Ser derechohabiente con relación laboral vigente y cotizaciones al corriente.
- Tener los puntos mínimos exigidos por el Infonavit (precalificación en el portal del instituto con CURP y NSS).
- No tener otro crédito Infonavit vigente.
- Documentos habituales: identificación oficial, CURP, acta de nacimiento, comprobante de domicilio, estados de cuenta y constancia de semanas cotizadas.
## Cofinavit (Infonavit + banco)
- Combina el crédito Infonavit con un crédito bancario complementario cuando el monto Infonavit no alcanza.
- Se deben cumplir los requisitos de ambas instituciones; el banco evalúa además historial y capacidad de pago.
## Crédito Fovissste
- Para trabajadores del Estado con cotizaciones al Fovissste; la modalidad tradicional es por sorteo.
- Documentos habituales: identificación oficial, CURP, comprobante de domicilio, talones de pago y constancia de servicios.
## Crédito bancario tradicional
- Historial en buró de crédito sano (en general score alto y sin atrasos recientes).
- Comprobación de ingresos: empleados asalariados presentan carta de empleo, recibos de nómina de los últimos 3 meses y estados de cuenta donde se refleje la nómina.
- Enganche típico del 10% al 20% del valor de la vivienda, según el banco.
## Documentos que Frasa solicita para el expediente de crédito
- Solicitud de crédito hipotecario (en caso de Infonavit, la hoja 3 del formato).
- Autorización de consulta a buró de crédito.
- Identificación oficial vigente con fotografía y firma.
- Acta de nacimiento original (y acta de matrimonio, en su caso).
- Comprobante de domicilio reciente.
- Asalariados: carta original de empleo (papelería membretada, con puesto, antigüedad, NSS e ingresos brutos), 3 meses de recibos de nómina y 3 meses de estados de cuenta con abono de nómina.
- Personas físicas con actividad empresarial: alta en Hacienda, declaraciones anuales de los últimos 2 ejercicios y 6 meses de estados de cuenta.
- Personas con ingresos en EE.UU.: carta de empleo, formatos W2 y 1040 de un año, 3 meses de recibos de nómina, reporte de buró de EE.UU. con score superior a 680, 6 meses de estados de cuenta y copia del Social Security; si tiene actividad empresarial en EE.UU., además carta de actividad, formato 1099, permiso de operación vigente y 3 referencias.

View File

@@ -12,6 +12,7 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"seed:ai": "tsx scripts/seed-ai.ts",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {

View File

@@ -129,3 +129,45 @@ model NotaPlan {
@@index([planId])
}
// ---------- Agente IA interno (RAG) ----------
// Documento de la base de conocimiento; `hash` (sha256 del contenido) permite
// re-indexar solo cuando cambió.
model AIDocumento {
id String @id @default(uuid())
titulo String @unique
contenido String
hash String
createdAt DateTime @default(now())
chunks AIChunk[]
}
model AIChunk {
id String @id @default(uuid())
documentoId String
documento AIDocumento @relation(fields: [documentoId], references: [id], onDelete: Cascade)
texto String
embedding String // JSON array de floats (SQLite no tiene vector nativo)
orden Int
@@index([documentoId])
}
model AIConversacion {
id String @id @default(uuid())
createdAt DateTime @default(now())
mensajes AIMensaje[]
}
model AIMensaje {
id String @id @default(uuid())
conversacionId String
conversacion AIConversacion @relation(fields: [conversacionId], references: [id], onDelete: Cascade)
rol String // user | assistant
contenido String
fuentes String? // JSON [{titulo, score}] solo en mensajes del asistente
createdAt DateTime @default(now())
@@index([conversacionId])
}

View File

@@ -0,0 +1,41 @@
/**
* Seed de la base de conocimiento del agente IA.
* Lee knowledge/*.md, usa el primer encabezado H1 como título y los indexa
* vía indexarDocumento (idempotente: solo re-embebe si cambió el hash).
*
* Uso: npm run seed:ai (requiere NAN_API_KEY en el .env del ecosistema)
*/
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { indexarDocumento } from '../src/services/ai.service.js'
const KNOWLEDGE_DIR = path.resolve(process.cwd(), 'knowledge')
function tituloDe(contenido: string, archivo: string): string {
const h1 = contenido.split('\n').find((l) => l.startsWith('# '))
return h1 ? h1.replace(/^#\s+/, '').trim() : archivo.replace(/\.md$/, '')
}
async function main(): Promise<void> {
const archivos = (await readdir(KNOWLEDGE_DIR)).filter((f) => f.endsWith('.md')).sort()
if (archivos.length === 0) {
console.error(`No hay archivos .md en ${KNOWLEDGE_DIR}`)
process.exit(1)
}
console.log(`Indexando ${archivos.length} documentos desde ${KNOWLEDGE_DIR}…`)
let totalChunks = 0
for (const archivo of archivos) {
const contenido = await readFile(path.join(KNOWLEDGE_DIR, archivo), 'utf8')
const titulo = tituloDe(contenido, archivo)
const r = await indexarDocumento(titulo, contenido)
totalChunks += r.chunks
console.log(` ${r.estado.padEnd(12)} ${titulo} (${r.chunks} chunks)`)
}
console.log(`Listo: ${archivos.length} documentos procesados.`)
process.exit(0)
}
main().catch((err) => {
console.error('Falló el seed:', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -38,6 +38,15 @@ export const config = {
pollCron: env('POLL_CRON', '*/5 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
},
ai: {
// Proveedor NAN (OpenAI-compatible): chat + embeddings
apiKey: env('NAN_API_KEY'),
baseUrl: env('NAN_BASE_URL', 'https://api.nan.builders/v1'),
chatModel: env('NAN_CHAT_MODEL', 'qwen3.6'),
embedModel: env('NAN_EMBED_MODEL', 'qwen3-embedding'),
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
minScore: Number(env('AI_MIN_SCORE', '0.3')),
},
} as const
export type AppConfig = typeof config

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

View File

@@ -28,6 +28,14 @@ import {
estadoAutomatizaciones,
previewInactividad,
} from '../controllers/automatizaciones.controller.js'
import {
chatIA,
eliminarDocumento,
listarConversaciones,
listarDocumentosIA,
obtenerConversacion,
subirDocumento,
} from '../controllers/ai.controller.js'
export const apiRouter = Router()
@@ -58,3 +66,10 @@ apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
apiRouter.post('/ai/chat', chatIA)
apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)

View File

@@ -0,0 +1,279 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NanClient } from './ai.service.js'
import {
chat,
construirSystemPrompt,
decidirIndexacion,
dedupeFuentes,
fragmentar,
hashContenido,
indexarDocumento,
rankearChunks,
similitudCoseno,
} from './ai.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
const prismaMock = vi.hoisted(() => ({
aIDocumento: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
aIChunk: {
deleteMany: vi.fn(),
findMany: vi.fn(),
},
aIConversacion: {
findUnique: vi.fn(),
create: vi.fn(),
},
aIMensaje: {
create: vi.fn(),
findMany: vi.fn(),
},
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
/** Cliente NAN falso: embeddings deterministas por longitud y chat eco */
function clienteFalso(): NanClient & { llamadasEmbed: string[][] } {
const llamadasEmbed: string[][] = []
return {
llamadasEmbed,
async embed(textos: string[]) {
llamadasEmbed.push(textos)
return textos.map((t) => [t.length, t.length % 7, 1])
},
async chat() {
return 'Respuesta simulada del asistente.\n\nFuentes: Doc de prueba'
},
}
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------- Chunking ----------
describe('fragmentar', () => {
it('devuelve un solo chunk para textos cortos', () => {
const chunks = fragmentar('Hola mundo.\n\nSegundo párrafo.')
expect(chunks).toHaveLength(1)
expect(chunks[0]).toContain('Hola mundo')
expect(chunks[0]).toContain('Segundo párrafo')
})
it('divide por párrafos respetando el tamaño máximo', () => {
const parrafos = Array.from({ length: 10 }, (_, i) => `Párrafo ${i}: ${'x'.repeat(100)}`)
const chunks = fragmentar(parrafos.join('\n\n'), 400, 50)
expect(chunks.length).toBeGreaterThan(2)
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(400 + 50)
})
it('aplica solape entre chunks consecutivos', () => {
const parrafos = Array.from({ length: 6 }, (_, i) => `P${i} ${'abcde'.repeat(30)}`)
const chunks = fragmentar(parrafos.join('\n\n'), 350, 60)
expect(chunks.length).toBeGreaterThan(1)
// el inicio de un chunk comparte caracteres con el final del anterior
const cola = chunks[0]!.slice(-30)
expect(chunks[1]!.includes(cola)).toBe(true)
})
it('corta duro un párrafo que excede el tamaño máximo sin perder texto', () => {
const largo = 'y'.repeat(1500)
const chunks = fragmentar(largo, 500, 100)
expect(chunks.length).toBeGreaterThanOrEqual(3)
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500)
// sin solape habría 1500 chars útiles; con solape el total crece pero nada se pierde
expect(chunks.join('').length).toBeGreaterThanOrEqual(1500)
})
it('ignora párrafos vacíos', () => {
expect(fragmentar('\n\n\n \n\n')).toHaveLength(0)
})
})
// ---------- Coseno y ranking ----------
describe('similitudCoseno', () => {
it('es 1 para vectores idénticos y 0 para ortogonales', () => {
expect(similitudCoseno([1, 2, 3], [1, 2, 3])).toBeCloseTo(1)
expect(similitudCoseno([1, 0], [0, 1])).toBeCloseTo(0)
})
it('es invariante a la escala', () => {
expect(similitudCoseno([1, 1], [5, 5])).toBeCloseTo(1)
})
it('devuelve 0 si algún vector es cero', () => {
expect(similitudCoseno([0, 0], [1, 1])).toBe(0)
})
})
describe('rankearChunks', () => {
const chunks = [
{ titulo: 'A', texto: 'a', embedding: [1, 0] },
{ titulo: 'B', texto: 'b', embedding: [0.9, 0.1] },
{ titulo: 'C', texto: 'c', embedding: [0, 1] },
{ titulo: 'D', texto: 'd', embedding: [0.8, 0.2] },
]
it('ordena por score descendente y aplica top-k', () => {
const top = rankearChunks(chunks, [1, 0], 2, 0)
expect(top.map((t) => t.titulo)).toEqual(['A', 'B'])
})
it('filtra por el umbral mínimo', () => {
// coseno([1,0],[0.9,0.1]) ≈ 0.994 y coseno([1,0],[0.8,0.2]) ≈ 0.970
const top = rankearChunks(chunks, [1, 0], 4, 0.995)
expect(top.map((t) => t.titulo)).toEqual(['A'])
})
it('devuelve vacío si nada supera el umbral', () => {
expect(rankearChunks(chunks, [0, 1], 4, 0.999)).toEqual([{ titulo: 'C', texto: 'c', score: 1 }])
expect(rankearChunks(chunks, [0, 1], 4, 1.0001)).toEqual([])
})
})
// ---------- System prompt ----------
describe('construirSystemPrompt', () => {
it('incluye el contexto numerado con títulos de fuente y las reglas', () => {
const prompt = 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('[1] Fuente: Catálogo DIAGONAL')
expect(prompt).toContain('[2] Fuente: Proceso de venta')
expect(prompt).toContain('Modelo 1R desde $2,850,000')
expect(prompt).toContain('Fuentes:')
})
it('marca explícitamente el contexto vacío', () => {
const prompt = construirSystemPrompt([])
expect(prompt).toContain('CONTEXTO: (vacío')
})
})
// ---------- Dedup por hash ----------
describe('hashContenido / decidirIndexacion', () => {
it('el hash es estable y cambia con el contenido', () => {
expect(hashContenido('abc')).toBe(hashContenido('abc'))
expect(hashContenido('abc')).not.toBe(hashContenido('abd'))
})
it('decide crear/actualizar/omitir según existencia y hash', () => {
expect(decidirIndexacion(null, 'h1')).toBe('crear')
expect(decidirIndexacion({ hash: 'h1' }, 'h1')).toBe('omitir')
expect(decidirIndexacion({ hash: 'h1' }, 'h2')).toBe('actualizar')
})
})
describe('indexarDocumento (dedup por hash)', () => {
it('crea un documento nuevo con sus chunks embebidos', async () => {
prismaMock.aIDocumento.findUnique.mockResolvedValue(null)
prismaMock.aIDocumento.create.mockResolvedValue({ id: 'doc-1' })
const client = clienteFalso()
const r = await indexarDocumento('Doc de prueba', 'Párrafo único de prueba.', client)
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'creado', chunks: 1 })
expect(client.llamadasEmbed).toEqual([['Párrafo único de prueba.']])
expect(prismaMock.aIDocumento.create).toHaveBeenCalledOnce()
})
it('omite la re-indexación si el hash no cambió (no llama embeddings)', async () => {
const contenido = 'Contenido sin cambios.'
prismaMock.aIDocumento.findUnique.mockResolvedValue({
id: 'doc-1',
hash: hashContenido(contenido),
chunks: [{ id: 'c1' }, { id: 'c2' }],
})
const client = clienteFalso()
const r = await indexarDocumento('Doc de prueba', contenido, client)
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'sin-cambios', chunks: 2 })
expect(client.llamadasEmbed).toHaveLength(0)
expect(prismaMock.aIDocumento.create).not.toHaveBeenCalled()
expect(prismaMock.aIDocumento.update).not.toHaveBeenCalled()
})
it('re-indexa borrando chunks viejos si el hash cambió', async () => {
prismaMock.aIDocumento.findUnique.mockResolvedValue({
id: 'doc-1',
hash: 'hash-viejo',
chunks: [{ id: 'c1' }],
})
prismaMock.aIDocumento.update.mockResolvedValue({ id: 'doc-1' })
const client = clienteFalso()
const r = await indexarDocumento('Doc de prueba', 'Contenido nuevo.', client)
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'actualizado', chunks: 1 })
expect(prismaMock.aIChunk.deleteMany).toHaveBeenCalledWith({ where: { documentoId: 'doc-1' } })
expect(prismaMock.aIDocumento.update).toHaveBeenCalledOnce()
})
})
// ---------- Chat RAG ----------
describe('chat', () => {
it('crea conversación nueva, recupera contexto y persiste ambos mensajes', async () => {
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-1', createdAt: new Date() })
prismaMock.aIChunk.findMany.mockResolvedValue([
{ texto: 'Precios DIAGONAL', embedding: JSON.stringify([16, 2, 1]), documento: { titulo: 'Catálogo' } },
{ texto: 'Otro tema', embedding: JSON.stringify([1, 0, 500]), documento: { titulo: 'FAQ' } },
])
prismaMock.aIMensaje.findMany.mockResolvedValue([
{ rol: 'user', contenido: '¿precios?' },
])
prismaMock.aIMensaje.create.mockResolvedValue({})
const client = clienteFalso()
const r = await chat(null, '¿precios?', client)
expect(r.sessionId).toBe('conv-1')
expect(r.respuesta).toContain('Respuesta simulada')
// '¿precios?' tiene longitud 9 → embedding [9, 2, 1]; el chunk "Precios" [16,2,1] es el más cercano
expect(r.fuentes.length).toBeGreaterThan(0)
// se persisten el mensaje del usuario y el del asistente
expect(prismaMock.aIMensaje.create).toHaveBeenCalledTimes(2)
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
expect(roles).toEqual(['user', 'assistant'])
})
it('reutiliza la conversación existente y falla con sessionId desconocido', async () => {
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce({ id: 'conv-9', createdAt: new Date() })
prismaMock.aIChunk.findMany.mockResolvedValue([])
prismaMock.aIMensaje.findMany.mockResolvedValue([{ rol: 'user', contenido: 'hola' }])
prismaMock.aIMensaje.create.mockResolvedValue({})
const client = clienteFalso()
const r = await chat('conv-9', 'hola', client)
expect(r.sessionId).toBe('conv-9')
expect(prismaMock.aIConversacion.create).not.toHaveBeenCalled()
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce(null)
await expect(chat('no-existe', 'hola', client)).rejects.toThrow('Conversación no encontrada')
})
})
describe('dedupeFuentes', () => {
it('conserva el mejor score por título', () => {
const fuentes = dedupeFuentes([
{ titulo: 'A', texto: '', score: 0.5 },
{ titulo: 'A', texto: '', score: 0.91234 },
{ titulo: 'B', texto: '', score: 0.7 },
])
expect(fuentes).toEqual([
{ titulo: 'A', score: 0.912 },
{ titulo: 'B', score: 0.7 },
])
})
})

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