feat: mis cobranzas, recordatorios de pago y agente IA con acciones

- PlanPago.vendedorId + filtro ?mios=1; toggle Mis planes/Todos en /pay
- Motor de recordatorios: próximos a vencer (3d) y vencidos (re-cada 7d),
  canal Monday Actividad + stub WhatsApp listo para WA CRM, log persistente,
  cron diario 9:30, run manual con dry-run, historial en /api/cobranza/recordatorios
- Agente IA: memoria por usuario (conversaciones aisladas) y tool calling:
  cotizar_unidad (motor real por desarrollo) y buscar_unidad (inventario Monday)
- Tests: api 261, cobranza 75, agente-ia 13
This commit is contained in:
2026-08-28 08:07:12 +00:00
parent 59944668a8
commit aa27e89c75
18 changed files with 1437 additions and 17 deletions

View File

@@ -2,6 +2,16 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { mondayService } from './monday.service.js'
import {
APARTADO_DEFAULT,
calcularPropuesta,
obtenerPrecioLista,
TIPO_PROPUESTA_API,
totalPagado,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
@@ -10,9 +20,36 @@ export interface MensajeChat {
contenido: string
}
// Mensaje en el formato crudo de la API (incluye tool calls y respuestas de tools)
export interface MensajeApi {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]
tool_call_id?: string
}
export interface ToolCallSolicitado {
id: string
nombre: string
argumentosJson: string
}
export interface RespuestaCruda {
contenido: string | null
toolCalls: ToolCallSolicitado[]
}
/** Definición de herramienta en el formato function-calling de OpenAI */
export interface HerramientaDef {
type: 'function'
function: { name: string; description: string; parameters: Record<string, unknown> }
}
export interface NanClient {
chat(mensajes: MensajeChat[]): Promise<string>
embed(textos: string[]): Promise<number[][]>
/** Chat con herramientas (function calling); opcional para compat con clientes simples */
chatConTools?(mensajes: MensajeApi[], herramientas: HerramientaDef[]): Promise<RespuestaCruda>
}
interface NanOpciones {
@@ -47,6 +84,32 @@ export function crearNanClient(opciones: NanOpciones): NanClient {
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
return contenido
},
async chatConTools(mensajes, herramientas) {
const body: Record<string, unknown> = {
model: opciones.chatModel,
messages: mensajes,
temperature: 0.2,
}
// Sin herramientas registradas el proveedor responde texto plano (cierre del loop)
if (herramientas.length > 0) {
body['tools'] = herramientas
body['tool_choice'] = 'auto'
}
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, body)) as {
choices?: {
message?: {
content?: string | null
tool_calls?: { id: string; type: string; function: { name: string; arguments: string } }[]
}
}[]
}
const message = data.choices?.[0]?.message
if (!message) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
const toolCalls = (message.tool_calls ?? [])
.filter((t) => t.type === 'function')
.map((t) => ({ id: t.id, nombre: t.function.name, argumentosJson: t.function.arguments }))
return { contenido: message.content ?? null, toolCalls }
},
async embed(textos) {
if (textos.length === 0) return []
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
@@ -186,6 +249,13 @@ export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
'- 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>".',
'',
'HERRAMIENTAS:',
'- Tienes herramientas para cotizar una unidad con el motor del cotizador y para buscar unidades',
' disponibles. Úsalas cuando te pidan números o disponibilidad; NUNCA inventes precios ni cifras.',
'- Cuando uses cotizar_unidad, presenta enganche, mensualidades y pago final, y aclara que la',
' cotización es ORIENTATIVA: el PDF oficial se genera en el cotizador (/quote/).',
'- Si la unidad no existe o no está DISPONIBLE, dilo claramente y ofrece buscar alternativas.',
'',
contexto.length > 0
? `CONTEXTO:\n\n${bloques}`
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
@@ -270,6 +340,160 @@ export async function indexarDocumento(
return { titulo, estado: 'creado', chunks: textos.length }
}
// ---------- Herramientas (function calling): cotizar y buscar unidades ----------
const TIPOS_PROPUESTA_VALIDOS = Object.keys(TIPO_PROPUESTA_API) as TipoPropuestaApi[]
export const HERRAMIENTAS: HerramientaDef[] = [
{
type: 'function',
function: {
name: 'cotizar_unidad',
description:
'Cotiza una unidad con el motor del cotizador (enganche, mensualidad, pago final, total). ' +
'Úsala cuando pidan números de una unidad concreta. Requiere el código de la unidad (p. ej. SOLANA-6).',
parameters: {
type: 'object',
properties: {
codigo: { type: 'string', description: 'Código de la unidad, p. ej. SOLANA-6' },
tipoPropuesta: {
type: 'string',
enum: TIPOS_PROPUESTA_VALIDOS,
description: 'Esquema de pago; default BANCARIO',
},
apartado: { type: 'number', description: `Monto del apartado en MXN; default ${APARTADO_DEFAULT}` },
acabados: { type: 'boolean', description: 'Incluir cocina y closet (default false)' },
},
required: ['codigo'],
},
},
},
{
type: 'function',
function: {
name: 'buscar_unidad',
description:
'Busca hasta 5 unidades DISPONIBLE que cumplan los filtros (desarrollo, modelo, precio máximo).',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo, p. ej. SOLANA, DIAGONAL, ATELIER' },
modelo: { type: 'string', description: 'Texto contenido en el nombre del modelo' },
precioMax: { type: 'number', description: 'Precio de lista máximo en MXN (sin acabados)' },
},
},
},
},
]
export interface ArgsCotizarUnidad {
codigo?: string
tipoPropuesta?: string
apartado?: number
acabados?: boolean
}
export interface ArgsBuscarUnidad {
desarrollo?: string
modelo?: string
precioMax?: number
}
export interface EjecutoresHerramientas {
cotizar_unidad(args: ArgsCotizarUnidad): Promise<unknown>
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
}
/** Redondeo monetario a 2 decimales para los números que lee el modelo */
const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100)
/**
* Ejecutores reales: inventario desde Monday (o el buscador inyectado en tests)
* y cálculo con el motor de cotización portado de los Excel.
*/
export function crearEjecutoresHerramientas(
buscarUnidades: () => Promise<UnidadCotizable[]> = () => mondayService.getUnidades(),
): EjecutoresHerramientas {
return {
async cotizar_unidad(args) {
const codigo = (args.codigo ?? '').trim().toUpperCase()
if (codigo === '') return { error: 'Falta el código de la unidad' }
const unidades = await buscarUnidades()
const unidad = unidades.find((u) => u.codigo.toUpperCase() === codigo)
if (!unidad) return { error: `La unidad ${codigo} no existe en el inventario` }
if (unidad.status !== 'DISPONIBLE') {
return { error: `La unidad ${codigo} no está DISPONIBLE (status actual: ${unidad.status})` }
}
const tipo = (args.tipoPropuesta ?? 'BANCARIO').trim().toUpperCase()
if (!TIPOS_PROPUESTA_VALIDOS.includes(tipo as TipoPropuestaApi)) {
return { error: `tipoPropuesta inválido: ${tipo}. Válidos: ${TIPOS_PROPUESTA_VALIDOS.join(', ')}` }
}
const apartado =
typeof args.apartado === 'number' && Number.isFinite(args.apartado) && args.apartado > 0
? args.apartado
: APARTADO_DEFAULT
const acabados = args.acabados === true
const propuesta = calcularPropuesta(unidad, tipo as TipoPropuestaApi, acabados, apartado)
return {
unidad: unidad.codigo,
desarrollo: unidad.desarrollo,
modelo: unidad.modelo,
esquema: propuesta.titulo,
precioLista: r2(obtenerPrecioLista(unidad, acabados)),
descuento: propuesta.descuento,
precioFinal: r2(propuesta.precioFinal),
apartado: r2(apartado),
enganche: r2(propuesta.enganche),
numMensualidades: propuesta.numMensualidades,
mensualidad: r2(propuesta.mensualidad),
mensualidadesTotal: r2(propuesta.mensualidadesTotal),
pagoFinal: r2(propuesta.pagoFinal),
totalPagado: r2(totalPagado(propuesta, apartado)),
nota: 'Cotización orientativa; el PDF oficial se genera en el cotizador (/quote/).',
}
},
async buscar_unidad(args) {
const unidades = await buscarUnidades()
let lista = unidades.filter((u) => u.status === 'DISPONIBLE')
const desarrollo = args.desarrollo?.trim().toUpperCase()
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
const modelo = args.modelo?.trim().toLowerCase()
if (modelo) lista = lista.filter((u) => u.modelo.toLowerCase().includes(modelo))
if (typeof args.precioMax === 'number' && Number.isFinite(args.precioMax)) {
lista = lista.filter((u) => u.precioSinAcabados <= args.precioMax!)
}
return lista.slice(0, 5).map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo,
precio: r2(u.precioSinAcabados),
precioConAcabados: r2(u.precioConAcabados),
supConstruccion: u.supConstruccion,
}))
},
}
}
/** Ejecuta un tool call contra los ejecutores; nunca lanza (el error va al modelo) */
export async function ejecutarToolCall(
call: ToolCallSolicitado,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
let args: Record<string, unknown>
try {
args = JSON.parse(call.argumentosJson || '{}') as Record<string, unknown>
} catch {
return JSON.stringify({ error: `Argumentos inválidos para ${call.nombre}` })
}
const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas]
if (!ejecutor) return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` })
try {
return JSON.stringify(await ejecutor(args))
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
}
}
// ---------- Chat RAG ----------
const HISTORIAL_MAX = 10
@@ -295,16 +519,77 @@ export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
}
export interface OpcionesChat {
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
usuarioId?: string | null
/** Ejecutores de herramientas (default: los reales contra Monday + motor de cotización) */
ejecutores?: EjecutoresHerramientas
}
/** Máximo de rondas de ejecución de herramientas por mensaje */
export const MAX_RONDAS_TOOLS = 2
/**
* Loop de tool calling: si el modelo pide herramientas, se ejecutan y se hace
* una segunda llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después
* se fuerza el cierre sin herramientas). Sin chatConTools en el cliente, chat plano.
*/
async function responderConHerramientas(
mensajes: MensajeApi[],
client: NanClient,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
if (!client.chatConTools) {
// Cliente simple (tests legados): chat plano sin tools
return client.chat(
mensajes
.filter((m) => m.role !== 'tool')
.map((m) => ({
rol: m.role === 'assistant' ? ('assistant' as const) : m.role === 'system' ? ('system' as const) : ('user' as const),
contenido: m.content ?? '',
})),
)
}
let herramientas = HERRAMIENTAS
for (let ronda = 0; ronda <= MAX_RONDAS_TOOLS; ronda++) {
const resp = await client.chatConTools(mensajes, herramientas)
if (resp.toolCalls.length === 0) {
if (resp.contenido) return resp.contenido
throw new AppError(502, 'El proveedor IA no devolvió respuesta')
}
if (ronda === MAX_RONDAS_TOOLS) break // no debería pasar: la última llamada va sin tools
mensajes.push({
role: 'assistant',
content: resp.contenido,
tool_calls: resp.toolCalls.map((t) => ({
id: t.id,
type: 'function' as const,
function: { name: t.nombre, arguments: t.argumentosJson },
})),
})
for (const call of resp.toolCalls) {
const resultado = await ejecutarToolCall(call, ejecutores)
mensajes.push({ role: 'tool', tool_call_id: call.id, content: resultado })
}
if (ronda === MAX_RONDAS_TOOLS - 1) herramientas = [] // cierre: forzar texto plano
}
throw new AppError(502, 'El proveedor IA no cerró la respuesta tras usar herramientas')
}
export async function chat(
sessionId: string | null,
mensaje: string,
client: NanClient = clienteNan(),
opciones: OpcionesChat = {},
): 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: {} })
conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null },
})
await prisma.aIMensaje.create({
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
@@ -334,12 +619,16 @@ export async function chat(
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 mensajes: MensajeApi[] = [
{ role: 'system', content: construirSystemPrompt(top) },
...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })),
]
const respuesta = await client.chat(mensajes)
const respuesta = await responderConHerramientas(
mensajes,
client,
opciones.ejecutores ?? crearEjecutoresHerramientas(),
)
const fuentes = dedupeFuentes(top)
await prisma.aIMensaje.create({