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

@@ -0,0 +1,76 @@
# Guía operativa del sistema Frasa (CRM360)
Sistema interno de Frasa Desarrollos. Acceso: https://frasa.consultoria-as.com
Todo el trabajo vive en el sistema: ya no se usa Monday ni hojas de cálculo sueltas.
## Módulos y para qué sirve cada uno
- **Mi Día** (`/hoy`): centro de trabajo del vendedor. Pestañas: Mis leads, Mis oportunidades,
Mis apartados, Escrituración, Mi tablero. Cada card muestra los datos del contacto, el último
cambio (quién y cuándo) y los botones para moverlo de etapa. Al dar clic en el nombre se abre
la ficha con información, cita, tareas y bitácora.
- **Alta Afluencia** (`/client`, pestaña Afluencia): registro del prospecto con perfilamiento
(edad, estado civil, empleo, ocupación, rango salarial, crédito, economía, califica,
dependientes, para qué lo quiere). La afluencia entra como **Oportunidad** en etapa Afluencia.
- **Alta de Cliente** (`/client`): expediente completo por etapas que se guardan solas — datos
generales, domicilio, contacto, empleo, económicos, co-acreditado, referencias, vivienda,
crédito y documentos (INE obligatoria). Genera los PDFs del expediente.
- **Cotizador** (`/quote`): cuatro propuestas comerciales (bancario, infonavit/cofinavit,
contado y semi-contado) sobre el inventario vivo, con apartado configurable, acabados y
mensualidades diferibles. Genera el PDF de la propuesta elegida y la guarda ligada al cliente.
- **Clientes / Cobranza** (`/pay`): planes de pago, cobranza del día, registro de abonos,
recibos y estados de cuenta en PDF, mora configurable y notas por plan.
- **Tablero** (`/dashboards`): Mi tablero y Tablero comercial (KPIs, equipo, perfilamiento)
y **Reportes** (10 reportes descargables en Excel).
- **Contactos**: lista de todos los contactos con su ficha e historial.
- **Clientes maestro**: base histórica de clientes con toda su información.
- **Captura** (`/captura`): expediente por departamentos (Obra, Ventas, Escrituración,
Finanzas, Postventa) por unidad y desarrollo. En Obra, cada trámite (certificación de gas,
TO y C4-fianza, régimen inscrito, no. oficial, clave catastral, listas para avalúo) lleva
fecha meta y check; la fecha se pinta verde al completarse y roja si se vence sin completar.
Las fechas ya establecidas solo las puede cambiar un administrador.
- **Soporte**: solicitudes internas a cualquier departamento con prioridad, responsable y fecha límite.
- **Chat Frasa** (`/ai`): este asistente.
- **Configuraciones** (solo admin): roles y permisos por rol, alta de usuarios, auditoría,
sistema y mantenimiento del asistente.
## Flujo completo de venta
1. **Afluencia**: el asesor registra al prospecto (Alta Afluencia) o captura rápida de lead.
Se asigna a quien lo captura.
2. **Cita**: desde Mis oportunidades se agenda con fecha y hora; aparece en el calendario.
3. **Cotización**: se cotiza la unidad interesada y la propuesta queda ligada al cliente.
4. **Apartado**: al apartar, la unidad se **bloquea del inventario** (no se puede vender dos
veces) y el cliente pasa a Mis apartados con el pipeline Enkontrol:
Pago Apartado → Alta Enkontrol → Entrego documentación → Cita cliente → Contrato Enkontrol →
Firma cliente.
5. **Escrituración**: tras la firma del cliente, el expediente pasa a Escrituración:
Venta → Seguimiento → Integración → X Autorizar → Autorizado → X Escriturar → Escriturado →
Titulado. Cualquier etapa puede cancelarse (Cancelo) indicando el motivo.
6. **Cobranza**: los planes de enganche diferido se siguen en Clientes/Cobranza, con abonos,
mora y estados de cuenta.
7. **Postventa**: entrega y seguimiento (SAC) — se captura en el módulo de Captura.
## Reglas importantes
- **Notas y bitácora**: cada contacto guarda su historial completo; las notas siguen al cliente
en todas las etapas (lead → oportunidad → apartado → escrituración).
- **Descartes y cancelaciones**: siempre se pide el motivo (categoría y subcategoría del catálogo
MOTIVOS NO COMPRA: Timing, Cliente, Competencia, Financiamiento, Ubicación, Producto, Precio).
- **Asignación**: los leads nuevos se reparten por round-robin entre los vendedores activos, y
se pueden reasignar manualmente (solo administradores).
- **Permisos**: cada rol ve los módulos que le corresponden; los vendedores solo ven sus propios
contactos, dirección y administración ven todo.
- **Documentos**: en los apartados son obligatorios la captura del cotizador, la captura del pago
de apartado y la INE.
## Preguntas frecuentes de operación
- **No me aparece una unidad en el cotizador**: si está apartada o vendida aparece bloqueada;
solo se cotizan unidades DISPONIBLES.
- **El desarrollo SOLE / MUTUO**: SOLE tiene modelos Alba, Horizonte y Ocaso; MUTUO captura el
modelo en texto libre.
- **¿Dónde veo quién atendió a un cliente?**: en la ficha del contacto (bitácora y último cambio)
y en el reporte de contactos.
- **¿Cómo descargo información?**: en Tablero → Reportes hay 10 reportes con filtros de fecha,
desarrollo y vendedor, todos descargables a Excel.

View File

@@ -16,7 +16,8 @@
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"prisma:migrate": "prisma migrate dev"
"prisma:migrate": "prisma migrate dev",
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"
},
"dependencies": {
"@prisma/client": "^6.14.0",

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Regenera los catálogos de unidades de la base de conocimiento del asistente
* a partir del INVENTARIO VIVO (tabla Unidad), para que el chat nunca conteste
* con un catálogo viejo.
*
* Uso: node scripts/actualizar-catalogos-ia.js
* Requiere NAN_API_KEY (embeddings) en el .env del ecosistema.
*
* Idempotente: indexarDocumento re-embebe solo si el contenido cambió.
*/
import { PrismaClient } from '@prisma/client'
import { readFileSync } from 'fs'
import { indexarDocumento } from '../dist/services/ai.service.js'
const prisma = new PrismaClient()
// Carga el .env del ecosistema (NAN_API_KEY / DATABASE_URL) si no vienen del entorno
for (const ruta of ['../.env', '../api-gateway/.env']) {
try {
const env = readFileSync(new URL(ruta, import.meta.url), 'utf8')
for (const linea of env.split('\n')) {
const m = linea.match(/^([A-Z_]+)=(.*)$/)
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
}
} catch { /* ignorar */ }
}
const fmtMoneda = (n) => `$${Math.round(n).toLocaleString('es-MX')}`
/** Markdown del catálogo de un desarrollo: modelos, disponibilidad y precios */
function catalogoDe(desarrollo, unidades) {
const porModelo = new Map()
for (const u of unidades) {
const modelo = u.modelo || '(sin modelo)'
const lista = porModelo.get(modelo) ?? []
lista.push(u)
porModelo.set(modelo, lista)
}
const conteoEstatus = {}
for (const u of unidades) conteoEstatus[u.status] = (conteoEstatus[u.status] ?? 0) + 1
const lineas = [
`# Catálogo de unidades — Desarrollo ${desarrollo}`,
'',
`Inventario actualizado desde el sistema (${unidades.length} unidades).`,
'',
'## Disponibilidad por estatus',
...Object.entries(conteoEstatus).map(([estatus, n]) => `- ${estatus}: ${n} unidades`),
'',
'## Modelos y precios',
]
for (const [modelo, lista] of [...porModelo.entries()].sort()) {
const disponibles = lista.filter((u) => u.status === 'DISPONIBLE')
const precios = lista.map((u) => u.precioLista).filter((p) => p > 0)
const min = precios.length > 0 ? Math.min(...precios) : 0
const max = precios.length > 0 ? Math.max(...precios) : 0
lineas.push(
`- **${modelo}**: ${lista.length} unidades (${disponibles.length} disponibles). ` +
(precios.length > 0 ? `Precio de lista ${fmtMoneda(min)} a ${fmtMoneda(max)}.` : 'Sin precio capturado.'),
)
if (disponibles.length > 0) {
const codigos = disponibles.slice(0, 12).map((u) => u.codigo).join(', ')
lineas.push(` - Disponibles: ${codigos}${disponibles.length > 12 ? ` (y ${disponibles.length - 12} más)` : ''}`)
}
}
lineas.push(
'',
'## Notas para el asistente',
'- Los precios son de lista, sin acabados (cocina y closet se cotizan aparte).',
'- Para los números de una unidad concreta (enganche, mensualidades, pago final) usa la herramienta cotizar_unidad.',
'- Para disponibilidad exacta en este momento usa buscar_unidad o contar_unidades: este documento es un resumen.',
)
return lineas.join('\n')
}
async function main() {
const unidades = await prisma.unidad.findMany({ orderBy: [{ desarrollo: 'asc' }, { modelo: 'asc' }, { codigo: 'asc' }] })
if (unidades.length === 0) {
console.error('No hay unidades en el inventario local; nada que regenerar.')
process.exit(1)
}
const porDesarrollo = new Map()
for (const u of unidades) {
const lista = porDesarrollo.get(u.desarrollo) ?? []
lista.push(u)
porDesarrollo.set(u.desarrollo, lista)
}
console.log(`Regenerando catálogos de ${porDesarrollo.size} desarrollo(s)…`)
for (const [desarrollo, lista] of [...porDesarrollo.entries()].sort()) {
const r = await indexarDocumento(`Catálogo de unidades — Desarrollo ${desarrollo}`, catalogoDe(desarrollo, lista))
console.log(` ${r.estado.padEnd(12)} ${r.titulo} (${r.chunks} chunks)`)
}
console.log('Listo.')
await prisma.$disconnect()
}
main().catch((err) => {
console.error('Falló:', err instanceof Error ? err.message : err)
process.exit(1)
})

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

View File

@@ -78,10 +78,14 @@ import {
} from '../controllers/automatizaciones.controller.js'
import {
chatIA,
chatIAStream,
eliminarConversacion,
eliminarDocumento,
estadoAsistente,
listarConversaciones,
listarDocumentosIA,
obtenerConversacion,
reindexarConocimiento,
subirDocumento,
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
@@ -284,11 +288,16 @@ apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
apiRouter.post('/ai/chat', chatIA)
apiRouter.post('/ai/chat/stream', chatIAStream)
apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
// ── Auditoría (admin) ────────────────────────────────────────────────
apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
import {
HERRAMIENTAS,
MAX_RONDAS_TOOLS,
chat,
construirSystemPrompt,
crearEjecutoresHerramientas,
@@ -169,8 +170,10 @@ describe('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('Chat Frasa')
expect(prompt).toContain('Nunca inventes cifras')
expect(prompt).toContain('buscar_contactos')
expect(prompt).toContain('estadisticas')
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')
@@ -631,13 +634,15 @@ describe('chat con herramientas (tool calling)', () => {
expect(roles).toEqual(['user', 'assistant'])
})
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
it('agota las rondas de tools: la última llamada va sin herramientas', async () => {
mocksPrismaChat()
const toolCall = (id: string): RespuestaCruda => ({
contenido: null,
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
})
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }])
// El cliente pide herramientas SIEMPRE; el loop debe cortar en MAX_RONDAS_TOOLS
const respuestas = Array.from({ length: MAX_RONDAS_TOOLS + 1 }, (_, i) => toolCall(`c${i + 1}`))
const client = clienteConTools(respuestas)
const ejecutores = {
async cotizar_unidad() {
return {}
@@ -659,11 +664,36 @@ describe('chat con herramientas (tool calling)', () => {
},
}
const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })
// La ronda de cierre (MAX_RONDAS_TOOLS) va sin herramientas y el cliente
// devuelve texto plano ahí (la última respuesta simulada es un tool call,
// así que el loop lanza al no poder cerrar con texto)
await expect(chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })).rejects.toThrow(
/no cerró la respuesta/,
)
expect(client.llamadas).toHaveLength(MAX_RONDAS_TOOLS + 1)
expect(client.llamadas[MAX_RONDAS_TOOLS]!.herramientas).toEqual([])
})
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
expect(client.llamadas[2]!.herramientas).toEqual([])
expect(r.respuesta).toBe('cierre forzado')
it('cierra con texto cuando el modelo deja de pedir herramientas', async () => {
mocksPrismaChat()
const toolCall = (id: string): RespuestaCruda => ({
contenido: null,
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
})
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre', toolCalls: [] }])
const r = await chat(null, 'búscame algo', client, {
ejecutores: {
cotizar_unidad: async () => ({}),
buscar_unidad: async () => [{ codigo: 'SOLANA-6' }],
buscar_leads: async () => [],
ver_cliente: async () => ({}),
ver_oportunidades: async () => [],
ver_cobranza: async () => ({}),
},
})
expect(client.llamadas).toHaveLength(3)
expect(r.respuesta).toBe('cierre')
expect(r.herramientasUsadas).toEqual(['buscar_unidad', 'buscar_unidad'])
})
it('respuesta directa sin tools: una sola llamada', async () => {

File diff suppressed because it is too large Load Diff