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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user