SEGURIDAD (agente IA): - Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican propiedad (antes cualquier usuario leia/continuaba la conversacion de otro). - /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar documentos de la base de conocimiento). DATOS / CORRUPCION: - Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead fantasma "Registro previo (CRM anterior)" en el embudo. - Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma. - Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su asesor real segun el Excel, el resto al admin) y el motor de recordatorios cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios se registraban 'sin-destinatario' y no los veia nadie. - Backups: script reescrito para el host real (sqlite .backup + uploads + env en /root/backups) y cron instalado (antes: script que asumia docker y sin cron programado = cero respaldos). REPORTES / METRICAS: - Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes 41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no existe); tasa de descarte ya compara el mismo periodo. - Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los descartados/perdidos ya no reciben score. - ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago que vence hoy. UI / FLUJOS: - mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion); confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban). - alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas; toast de exito solo cuando el API confirma; prefill de apartado trae direccion, estado civil y fecha de nacimiento desde el alta. - Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el API esperaba 'usuarioId') y 'hasta' incluye todo el dia. - Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de tiempo). - Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende ?cotizacion=<id> para retomar desde la liga. - Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados. - Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe). - Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real (antes limitado a 3 proyectos que no cubrian la cartera). Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza + 89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
1577 lines
60 KiB
TypeScript
1577 lines
60 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { prisma } from './prisma.js'
|
|
import { config } from '../config/index.js'
|
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
|
import { esAdminOManager } from '../utils/roles.js'
|
|
import { getUnidadesLocal } from './unidades-local.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) ----------
|
|
|
|
export interface MensajeChat {
|
|
rol: 'system' | 'user' | 'assistant'
|
|
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[],
|
|
onDelta?: (textoParcial: string) => void,
|
|
): Promise<RespuestaCruda>
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/** Acumulador de un stream OpenAI-compatible (deltas de texto y de tool_calls) */
|
|
async function leerStream(
|
|
res: globalThis.Response,
|
|
onDelta: (texto: string) => void,
|
|
): Promise<{ contenido: string; toolCalls: ToolCallSolicitado[] }> {
|
|
const reader = res.body?.getReader()
|
|
if (!reader) throw new AppError(502, 'El proveedor IA no devolvió stream')
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
let contenido = ''
|
|
const toolCalls: ToolCallSolicitado[] = []
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lineas = buffer.split('\n')
|
|
buffer = lineas.pop() ?? ''
|
|
for (const linea of lineas) {
|
|
const limpia = linea.trim()
|
|
if (!limpia.startsWith('data:')) continue
|
|
const payload = limpia.slice(5).trim()
|
|
if (payload === '' || payload === '[DONE]') continue
|
|
let json: {
|
|
choices?: {
|
|
delta?: {
|
|
content?: string | null
|
|
tool_calls?: { index?: number; id?: string; function?: { name?: string; arguments?: string } }[]
|
|
}
|
|
}[]
|
|
}
|
|
try {
|
|
json = JSON.parse(payload) as typeof json
|
|
} catch {
|
|
continue
|
|
}
|
|
const delta = json.choices?.[0]?.delta
|
|
if (!delta) continue
|
|
if (typeof delta.content === 'string' && delta.content !== '') {
|
|
contenido += delta.content
|
|
onDelta(delta.content)
|
|
}
|
|
for (const tc of delta.tool_calls ?? []) {
|
|
const idx = tc.index ?? 0
|
|
toolCalls[idx] ??= { id: tc.id ?? `call_${idx}`, nombre: '', argumentosJson: '' }
|
|
if (tc.id) toolCalls[idx]!.id = tc.id
|
|
if (tc.function?.name) toolCalls[idx]!.nombre += tc.function.name
|
|
if (tc.function?.arguments) toolCalls[idx]!.argumentosJson += tc.function.arguments
|
|
}
|
|
}
|
|
}
|
|
return { contenido, toolCalls: toolCalls.filter((t): t is ToolCallSolicitado => t !== undefined) }
|
|
}
|
|
|
|
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 chatConTools(mensajes, herramientas, onDelta) {
|
|
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'
|
|
}
|
|
if (onDelta) body['stream'] = true
|
|
|
|
const res = await fetch(`${opciones.baseUrl}/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opciones.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)}`)
|
|
}
|
|
|
|
// Streaming real cuando el proveedor lo soporta; si devuelve JSON, se usa tal cual
|
|
const tipo = res.headers.get('content-type') ?? ''
|
|
if (onDelta && tipo.includes('text/event-stream')) {
|
|
const { contenido, toolCalls } = await leerStream(res, onDelta)
|
|
return { contenido: contenido === '' ? null : contenido, toolCalls }
|
|
}
|
|
|
|
const data = (await res.json()) 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, {
|
|
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 Chat Frasa, el asistente interno de Frasa Desarrollos (desarrolladora de vivienda en Tijuana, B.C.).',
|
|
'Atiendes a vendedores, administración y dirección: tu trabajo es responder CUALQUIER pregunta sobre el',
|
|
'negocio — catálogo, precios, clientes, leads, apartados, escrituración, cobranza, obra, citas y soporte.',
|
|
'',
|
|
'FUENTES DE INFORMACIÓN (en orden de prioridad):',
|
|
'1) HERRAMIENTAS: son la fuente viva del sistema. Úsalas SIEMPRE que la pregunta sea sobre datos',
|
|
' concretos (personas, unidades, dinero, fechas, cantidades). Nunca inventes cifras, nombres ni fechas.',
|
|
'2) CONTEXTO (documentos internos): úsalo para procesos, políticas, requisitos y explicaciones.',
|
|
'',
|
|
'HERRAMIENTAS DISPONIBLES:',
|
|
'- buscar_unidad / contar_unidades: inventario por desarrollo, modelo, precio y estatus.',
|
|
'- cotizar_unidad: números de una unidad (enganche, mensualidades, pago final).',
|
|
'- buscar_contactos: cualquier persona del embudo con su perfilamiento completo (nombre, teléfono o email).',
|
|
'- ver_cliente: ficha completa de un cliente del alta (CURP, RFC, proyecto, unidad, cotizaciones).',
|
|
'- ver_bitacora: notas y seguimientos de un contacto, con fecha y autor.',
|
|
'- ver_apartados / ver_escrituracion: pipeline Enkontrol — fase, cliente, unidad y fechas.',
|
|
'- ver_cobranza: sin parámetros da el resumen global; con el nombre del cliente, su estado de cuenta',
|
|
' (pagado, saldo, abonos con fecha, vencidos y próximo pago).',
|
|
'- ver_captura: datos por departamento de una unidad o desarrollo (obra, ventas, escrituración, finanzas,',
|
|
' postventa) incluyendo trámites de obra vencidos.',
|
|
'- ver_calendario: citas y actividades de los próximos días.',
|
|
'- ver_soporte: solicitudes internas por estado y departamento.',
|
|
'- estadisticas: conteos y totales (leads nuevos por periodo y vendedor, pipeline por etapa, afluencias',
|
|
' que califican, inventario por estatus, resumen de cobranza y ranking de vendedores).',
|
|
'',
|
|
'CÓMO RESPONDER:',
|
|
'- Piensa qué datos necesitas y llama a las herramientas necesarias (puedes encadenar varias).',
|
|
'- Si una herramienta devuelve error o vacío, dilo con honestidad y sugiere la alternativa.',
|
|
'- Presenta los números con formato claro (moneda MXN con separador de miles y 2 decimales; fechas dd/mm/aaaa).',
|
|
'- Puedes usar tablas markdown cuando haya varios registros.',
|
|
'- Al final, si usaste documentos del contexto, cita las fuentes así: "Fuentes: <título 1>, <título 2>".',
|
|
'- Responde en español, claro y breve; explica los términos técnicos en lenguaje sencillo.',
|
|
'- Las cotizaciones son ORIENTATIVAS: el PDF oficial se genera en el cotizador (/quote/).',
|
|
'- No repitas datos personales innecesarios; el usuario ya trabaja con esos clientes.',
|
|
'',
|
|
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] ?? []),
|
|
})),
|
|
},
|
|
},
|
|
})
|
|
invalidarCacheChunks()
|
|
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] ?? []),
|
|
})),
|
|
},
|
|
},
|
|
})
|
|
invalidarCacheChunks()
|
|
return { titulo, estado: 'creado', chunks: textos.length }
|
|
}
|
|
|
|
// ---------- Herramientas (function calling) ----------
|
|
|
|
import { VOCABULARIO } from '../config/vocabulario.js'
|
|
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
|
|
|
const TIPOS_PROPUESTA_VALIDOS = Object.keys(TIPO_PROPUESTA_API) as TipoPropuestaApi[]
|
|
|
|
/** Contexto del usuario que pregunta: escopa los datos por rol */
|
|
export interface ContextoUsuario {
|
|
sub: string
|
|
rol: string
|
|
}
|
|
|
|
/** admin / director_ventas / direccion_general ven los datos de todos */
|
|
export function esDireccion(rol: string | undefined): boolean {
|
|
return (
|
|
rol === undefined || rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general'
|
|
)
|
|
}
|
|
|
|
const FASES_APARTADO: readonly string[] = VOCABULARIO.FASES_APARTADO
|
|
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
|
|
|
|
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 del inventario con filtros (desarrollo, modelo, precio máximo, estatus). ' +
|
|
'Por default solo DISPONIBLES; usa estatus para ver APARTADO, VENDIDO, ESCRITURADO o BLOQUEADO.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
desarrollo: { type: 'string', description: 'Desarrollo: SENDA, ATELIER, ESTANZA, DIAGONAL, SOLANA, SOLE, MUTUO' },
|
|
modelo: { type: 'string', description: 'Texto contenido en el nombre del modelo' },
|
|
precioMax: { type: 'number', description: 'Precio de lista máximo en MXN (sin acabados)' },
|
|
estatus: { type: 'string', description: 'Estatus exacto (DISPONIBLE por default)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'contar_unidades',
|
|
description:
|
|
'Cuenta unidades del inventario por desarrollo y estatus (p. ej. cuántas vendidas hay en SENDA). ' +
|
|
'Devuelve totales y el desglose por estatus.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
desarrollo: { type: 'string', description: 'Desarrollo a contar (opcional: todos)' },
|
|
estatus: { type: 'string', description: 'Filtrar por un estatus exacto (opcional)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'buscar_contactos',
|
|
description:
|
|
'Busca contactos (leads, afluencias, apartados) por nombre, teléfono o email, con su perfilamiento ' +
|
|
'completo (edad, estado civil, ocupación, rango salarial, crédito, califica, dependientes, para qué lo quiere). ' +
|
|
'Opcionalmente filtra por etapa o desarrollo. Úsala para cualquier pregunta sobre una persona.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
busqueda: { type: 'string', description: 'Nombre, teléfono o email (parcial)' },
|
|
etapa: { type: 'string', description: 'Etapa exacta del embudo (opcional)' },
|
|
desarrollo: { type: 'string', description: 'Desarrollo de interés (opcional)' },
|
|
limite: { type: 'number', description: 'Máximo de resultados (default 10, tope 25)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_cliente',
|
|
description:
|
|
'Información completa de un cliente del alta: datos personales, proyecto, unidad, cotizaciones y su lead.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { nombre: { type: 'string', description: 'Nombre del cliente (búsqueda parcial)' } },
|
|
required: ['nombre'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_cobranza',
|
|
description:
|
|
'Sin parámetros: resumen global de cobranza (planes activos, adeudo, vencidos, detalle). ' +
|
|
'Con `cliente`: el estado de cuenta de ese cliente (plan, pagado, saldo, abonos con fecha, mora y próximo pago).',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { cliente: { type: 'string', description: 'Nombre del cliente (parcial), opcional' } },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_apartados',
|
|
description:
|
|
'Apartados en proceso (pipeline Enkontrol) con su fase, cliente, unidad y vendedor. ' +
|
|
'Opcionalmente filtra por fase: ' + FASES_APARTADO.join(', '),
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { fase: { type: 'string', description: 'Fase exacta del apartado (opcional)' } },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_escrituracion',
|
|
description:
|
|
'Clientes en escrituración con su estatus (Venta, Seguimiento, Integración, Autorizado, ' +
|
|
'X Escriturar, Escriturado, Titulado, Cancelo) y las fechas de cada uno.',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_captura',
|
|
description:
|
|
'Datos de captura por departamento (obra, ventas, escrituración, finanzas, postventa) de una unidad o ' +
|
|
'de un desarrollo: fechas de trámites, estatus de venta, pagos y trámites de obra vencidos.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
desarrollo: { type: 'string', description: 'Desarrollo (opcional si se da unidad)' },
|
|
unidad: { type: 'string', description: 'Código de unidad exacto (opcional)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_bitacora',
|
|
description:
|
|
'Bitácora de un contacto: todas sus notas y seguimientos con fecha y quién los registró.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
contacto: { type: 'string', description: 'Nombre o teléfono del contacto' },
|
|
},
|
|
required: ['contacto'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_calendario',
|
|
description:
|
|
'Citas y actividades agendadas de los próximos días (por default 7). A los vendedores les muestra las ' +
|
|
'suyas; a dirección, las de todo el equipo.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { dias: { type: 'number', description: 'Días hacia adelante (default 7, tope 60)' } },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'ver_soporte',
|
|
description:
|
|
'Solicitudes internas de soporte con su estado, prioridad, departamento y solicitante.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
estado: { type: 'string', description: 'ABIERTA | EN_PROCESO | CERRADA (default ABIERTA)' },
|
|
departamento: { type: 'string', description: 'Departamento responsable (opcional)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'estadisticas',
|
|
description:
|
|
'Números del negocio. consulta: ' +
|
|
'leads_nuevos (altas por periodo y vendedor) · pipeline (registros por etapa del embudo) · ' +
|
|
'calificados (afluencias que califican) · unidades_por_estatus (inventario) · ' +
|
|
'cobranza_resumen (adeudo y vencidos) · ranking_vendedores (productividad por vendedor, solo dirección). ' +
|
|
'Úsala para cualquier pregunta de "cuántos/cuánto".',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
consulta: {
|
|
type: 'string',
|
|
enum: ['leads_nuevos', 'pipeline', 'calificados', 'unidades_por_estatus', 'cobranza_resumen', 'ranking_vendedores'],
|
|
},
|
|
dias: { type: 'number', description: 'Ventana en días para leads_nuevos y calificados (default 30)' },
|
|
desarrollo: { type: 'string', description: 'Filtro de desarrollo (unidades_por_estatus, calificados)' },
|
|
},
|
|
required: ['consulta'],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
export interface ArgsCotizarUnidad {
|
|
codigo?: string
|
|
tipoPropuesta?: string
|
|
apartado?: number
|
|
acabados?: boolean
|
|
}
|
|
|
|
export interface ArgsBuscarUnidad {
|
|
desarrollo?: string
|
|
modelo?: string
|
|
precioMax?: number
|
|
estatus?: string
|
|
}
|
|
|
|
export interface ArgsContarUnidades {
|
|
desarrollo?: string
|
|
estatus?: string
|
|
}
|
|
|
|
export interface ArgsBuscarContactos {
|
|
busqueda?: string
|
|
etapa?: string
|
|
desarrollo?: string
|
|
limite?: number
|
|
}
|
|
|
|
export interface ArgsBuscarLeads {
|
|
busqueda: string
|
|
}
|
|
|
|
export interface ArgsVerCliente {
|
|
nombre: string
|
|
}
|
|
|
|
export interface ArgsVerOportunidades {
|
|
etapa?: string
|
|
}
|
|
|
|
export interface ArgsVerCobranza {
|
|
cliente?: string
|
|
}
|
|
|
|
export interface ArgsVerCaptura {
|
|
desarrollo?: string
|
|
unidad?: string
|
|
}
|
|
|
|
export interface ArgsVerBitacora {
|
|
contacto: string
|
|
}
|
|
|
|
export interface ArgsVerCalendario {
|
|
dias?: number
|
|
}
|
|
|
|
export interface ArgsVerSoporte {
|
|
estado?: string
|
|
departamento?: string
|
|
}
|
|
|
|
export interface ArgsEstadisticas {
|
|
consulta: 'leads_nuevos' | 'pipeline' | 'calificados' | 'unidades_por_estatus' | 'cobranza_resumen' | 'ranking_vendedores'
|
|
dias?: number
|
|
desarrollo?: string
|
|
}
|
|
|
|
/** Ejecutores de herramientas. Los nuevos son opcionales para no romper clientes/tests previos. */
|
|
export interface EjecutoresHerramientas {
|
|
cotizar_unidad(args: ArgsCotizarUnidad): Promise<unknown>
|
|
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
|
|
ver_cliente(args: ArgsVerCliente): Promise<unknown>
|
|
ver_cobranza(args?: ArgsVerCobranza): Promise<unknown>
|
|
/** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */
|
|
buscar_leads?(args: ArgsBuscarLeads): Promise<unknown>
|
|
/** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */
|
|
ver_oportunidades?(args: ArgsVerOportunidades): Promise<unknown>
|
|
contar_unidades?(args: ArgsContarUnidades): Promise<unknown>
|
|
buscar_contactos?(args: ArgsBuscarContactos): Promise<unknown>
|
|
ver_apartados?(args: { fase?: string }): Promise<unknown>
|
|
ver_escrituracion?(): Promise<unknown>
|
|
ver_captura?(args: ArgsVerCaptura): Promise<unknown>
|
|
ver_bitacora?(args: ArgsVerBitacora): Promise<unknown>
|
|
ver_calendario?(args: ArgsVerCalendario): Promise<unknown>
|
|
ver_soporte?(args: ArgsVerSoporte): Promise<unknown>
|
|
estadisticas?(args: ArgsEstadisticas): 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)
|
|
|
|
const MS_DIA = 24 * 60 * 60 * 1000
|
|
|
|
function perfilPlano(datosJson: string | null): Record<string, string> {
|
|
if (!datosJson) return {}
|
|
try {
|
|
const datos = JSON.parse(datosJson) as Record<string, unknown>
|
|
const plano: Record<string, string> = {}
|
|
for (const k of ['numDependientes', 'edad', 'estadoCivil', 'tipoEmpleo', 'ocupacion', 'rangoSalarial', 'credito', 'tipoEconomia', 'califica', 'loQuierePara']) {
|
|
const v = datos[k]
|
|
if (v != null && typeof v !== 'object' && String(v).trim() !== '') plano[k] = String(v)
|
|
}
|
|
return plano
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ejecutores reales: inventario desde el espejo local de unidades (o el
|
|
* buscador inyectado en tests), cálculo con el motor de cotización portado de
|
|
* los Excel y consultas al CRM local (contactos, apartados, cobranza, captura,
|
|
* bitácora, calendario, soporte y estadísticas) escopadas por rol.
|
|
*/
|
|
export function crearEjecutoresHerramientas(
|
|
buscarUnidades: () => Promise<UnidadCotizable[]> = () => getUnidadesLocal(),
|
|
contexto?: ContextoUsuario,
|
|
): EjecutoresHerramientas {
|
|
const direccion = esDireccion(contexto?.rol)
|
|
const mio = contexto?.sub
|
|
|
|
/** Filtro de propiedad: dirección ve todo; el resto solo lo suyo */
|
|
const filtroPropio = (): Record<string, unknown> => (direccion || mio === undefined ? {} : { usuarioId: mio })
|
|
|
|
async function nombrePorUsuarioId(): Promise<Map<string, string>> {
|
|
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
|
|
return new Map(usuarios.map((u) => [u.id, u.nombre]))
|
|
}
|
|
|
|
async function unidadPorApartadoId(): Promise<Map<string, string>> {
|
|
const clientes = await prisma.cliente.findMany({
|
|
where: { mondayApartadoId: { not: null } },
|
|
select: { mondayApartadoId: true, unidadCodigo: true },
|
|
})
|
|
return new Map(clientes.filter((c) => c.mondayApartadoId !== null).map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']))
|
|
}
|
|
|
|
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()
|
|
const estatus = (args.estatus ?? 'DISPONIBLE').trim().toUpperCase()
|
|
let lista = unidades.filter((u) => (u.status || '').toUpperCase() === estatus)
|
|
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,
|
|
estatus: u.status,
|
|
precio: r2(u.precioSinAcabados),
|
|
precioConAcabados: r2(u.precioConAcabados),
|
|
supConstruccion: u.supConstruccion,
|
|
}))
|
|
},
|
|
|
|
async contar_unidades(args) {
|
|
const unidades = await buscarUnidades()
|
|
const desarrollo = args.desarrollo?.trim().toUpperCase()
|
|
const estatus = args.estatus?.trim().toUpperCase()
|
|
let lista = unidades
|
|
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
|
|
if (estatus) lista = lista.filter((u) => (u.status || '').toUpperCase() === estatus)
|
|
const porEstatus: Record<string, number> = {}
|
|
const porDesarrollo: Record<string, number> = {}
|
|
for (const u of lista) {
|
|
porEstatus[u.status] = (porEstatus[u.status] ?? 0) + 1
|
|
porDesarrollo[u.desarrollo] = (porDesarrollo[u.desarrollo] ?? 0) + 1
|
|
}
|
|
return { total: lista.length, porEstatus, porDesarrollo }
|
|
},
|
|
|
|
/** Legado (búsqueda ligera): el modelo usa buscar_contactos */
|
|
async buscar_leads(args) {
|
|
const termino = (args.busqueda ?? '').trim()
|
|
if (!termino) return { error: 'Falta el texto de búsqueda' }
|
|
const leads = await prisma.leadCaptura.findMany({
|
|
where: {
|
|
...filtroPropio(),
|
|
OR: [
|
|
{ nombreLead: { contains: termino } },
|
|
{ telefono: { contains: termino } },
|
|
{ email: { contains: termino } },
|
|
],
|
|
},
|
|
take: 10,
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
return leads.map((l) => ({
|
|
nombre: l.nombreLead,
|
|
telefono: l.telefono,
|
|
email: l.email,
|
|
estado: l.etapa,
|
|
origen: l.origen,
|
|
desarrollo: l.desarrollo,
|
|
board: l.board,
|
|
}))
|
|
},
|
|
|
|
async buscar_contactos(args) {
|
|
const termino = (args.busqueda ?? '').trim()
|
|
const where: Record<string, unknown> = { ...filtroPropio() }
|
|
if (termino !== '') {
|
|
where['OR'] = [
|
|
{ nombreLead: { contains: termino } },
|
|
{ telefono: { contains: termino } },
|
|
{ email: { contains: termino } },
|
|
]
|
|
}
|
|
if (args.etapa?.trim()) where['etapa'] = args.etapa.trim()
|
|
if (args.desarrollo?.trim()) where['desarrollo'] = args.desarrollo.trim().toUpperCase()
|
|
const limite = Math.min(Math.max(args.limite ?? 10, 1), 25)
|
|
const filas = await prisma.leadCaptura.findMany({
|
|
where,
|
|
take: limite,
|
|
orderBy: { updatedAt: 'desc' },
|
|
})
|
|
const nombres = await nombrePorUsuarioId()
|
|
return filas.map((c) => ({
|
|
nombre: c.nombreLead,
|
|
telefono: c.telefono ?? '',
|
|
email: c.email ?? '',
|
|
tablero: c.board,
|
|
etapa: c.etapa,
|
|
origen: c.origen ?? '',
|
|
desarrollo: c.desarrollo ?? '',
|
|
modelo: c.modelo ?? '',
|
|
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
|
|
motivo: c.motivo ?? undefined,
|
|
capturado: c.createdAt.toISOString().slice(0, 10),
|
|
perfil: perfilPlano(c.datosJson),
|
|
}))
|
|
},
|
|
|
|
async ver_cliente(args) {
|
|
const nombre = (args.nombre ?? '').trim()
|
|
if (!nombre) return { error: 'Falta el nombre del cliente' }
|
|
const cliente = await prisma.cliente.findFirst({ where: { nombreCompleto: { contains: nombre } } })
|
|
if (!cliente) return { error: `No se encontró ningún cliente con nombre "${nombre}"` }
|
|
|
|
const [leads, cotizaciones] = await Promise.all([
|
|
prisma.leadCaptura.findMany({
|
|
where: { nombreLead: { contains: cliente.nombreCompleto } },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 5,
|
|
}),
|
|
prisma.cotizacion.findMany({
|
|
where: { clienteNombre: { contains: cliente.nombreCompleto } },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 10,
|
|
}),
|
|
])
|
|
|
|
return {
|
|
id: cliente.id,
|
|
nombre: cliente.nombreCompleto,
|
|
curp: cliente.curp,
|
|
rfc: cliente.rfc,
|
|
email: cliente.email,
|
|
celular: cliente.celular,
|
|
proyecto: cliente.proyecto,
|
|
unidadCodigo: cliente.unidadCodigo,
|
|
perfil: perfilPlano(cliente.payloadJson),
|
|
lead: leads.length > 0
|
|
? {
|
|
etapa: leads[0]!.etapa,
|
|
board: leads[0]!.board,
|
|
origen: leads[0]!.origen,
|
|
}
|
|
: null,
|
|
cotizaciones: cotizaciones.map((c) => ({
|
|
unidad: c.unidadCodigo,
|
|
tipoPropuesta: c.tipoPropuesta,
|
|
precioFinal: r2(c.precioFinal),
|
|
enganche: r2(c.enganche),
|
|
mensualidad: r2(c.mensualidad),
|
|
createdAt: c.createdAt,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async ver_oportunidades(args) {
|
|
const where: Record<string, unknown> = { board: 'OPORTUNIDADES', ...filtroPropio() }
|
|
if (args.etapa) where['etapa'] = args.etapa.trim()
|
|
const oportunidades = await prisma.leadCaptura.findMany({ where, take: 20, orderBy: { createdAt: 'desc' } })
|
|
return oportunidades.map((o) => ({
|
|
nombre: o.nombreLead,
|
|
etapa: o.etapa,
|
|
desarrollo: o.desarrollo,
|
|
telefono: o.telefono,
|
|
email: o.email,
|
|
}))
|
|
},
|
|
|
|
async ver_cobranza(args) {
|
|
const cliente = (args?.cliente ?? '').trim()
|
|
|
|
// Estado de cuenta de un cliente
|
|
if (cliente !== '') {
|
|
const planes = await prisma.planPago.findMany({
|
|
where: {
|
|
clienteNombre: { contains: cliente },
|
|
...(direccion || mio === undefined ? {} : { vendedorId: mio }),
|
|
},
|
|
include: {
|
|
pagos: { include: { aplicaciones: { orderBy: { fechaPago: 'desc' }, take: 6 } }, orderBy: { numPago: 'asc' } },
|
|
},
|
|
})
|
|
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
|
const hoy = new Date()
|
|
// Medianoche de hoy: lo que vence HOY aún no está vencido
|
|
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
|
return planes.map((p) => {
|
|
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
|
|
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
|
|
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
|
|
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
|
|
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
|
const abonos = p.pagos
|
|
.flatMap((x) => x.aplicaciones.map((a) => ({ fecha: a.fechaPago.toISOString().slice(0, 10), monto: r2(a.monto) })))
|
|
.sort((a, b) => (b.fecha ?? '').localeCompare(a.fecha ?? ''))
|
|
.slice(0, 8)
|
|
return {
|
|
cliente: p.clienteNombre,
|
|
unidad: p.unidadCodigo,
|
|
desarrollo: p.proyecto,
|
|
planTotal: r2(p.engancheTotal),
|
|
mensualidad: r2(p.mensualidad),
|
|
numPagos: p.numPagos,
|
|
pagado: r2(pagado),
|
|
saldo: r2(saldo),
|
|
pagosVencidos: vencidos.length,
|
|
montoVencido: r2(vencidos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)),
|
|
proximoVencimiento: proximo ? proximo.fechaVencimiento.toISOString().slice(0, 10) : null,
|
|
estatus: p.status === 'CANCELADO' ? 'CANCELADO' : saldo <= 0.005 ? 'LIQUIDADO' : 'ACTIVO',
|
|
abonos,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Resumen global
|
|
const alcance = direccion || mio === undefined ? {} : { vendedorId: mio }
|
|
const [planesActivos, planesCancelados, todosPagos] = await Promise.all([
|
|
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
|
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
|
prisma.pago.findMany({
|
|
// Sólo planes activos: el adeudo de planes cancelados no cuenta
|
|
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
|
|
select: {
|
|
monto: true,
|
|
montoPagado: true,
|
|
fechaVencimiento: true,
|
|
plan: { select: { clienteNombre: true, unidadCodigo: true } },
|
|
},
|
|
}),
|
|
])
|
|
const pagosVencidos = todosPagos.filter((p) => p.montoPagado < p.monto)
|
|
const montoAdeudado = pagosVencidos.reduce((sum, p) => sum + (p.monto - p.montoPagado), 0)
|
|
return {
|
|
planesActivos,
|
|
planesCancelados,
|
|
pagosVencidos: pagosVencidos.length,
|
|
montoAdeudado: r2(montoAdeudado),
|
|
detalle: pagosVencidos.slice(0, 10).map((p) => ({
|
|
cliente: p.plan.clienteNombre,
|
|
unidad: p.plan.unidadCodigo,
|
|
montoAdeudado: r2(p.monto - p.montoPagado),
|
|
vencimiento: p.fechaVencimiento,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async ver_apartados(args) {
|
|
const fase = args.fase?.trim()
|
|
const where: Record<string, unknown> = {
|
|
board: 'APARTADO',
|
|
etapa: fase ? fase : { in: [...FASES_APARTADO] },
|
|
...filtroPropio(),
|
|
}
|
|
const filas = await prisma.leadCaptura.findMany({ where, orderBy: { updatedAt: 'desc' }, take: 40 })
|
|
const [nombres, unidades] = await Promise.all([nombrePorUsuarioId(), unidadPorApartadoId()])
|
|
return filas.map((c) => ({
|
|
cliente: c.nombreLead,
|
|
telefono: c.telefono ?? '',
|
|
unidad: unidades.get(c.leadId) ?? '',
|
|
fase: c.etapa,
|
|
desarrollo: c.desarrollo ?? '',
|
|
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
|
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : null,
|
|
ultimoCambio: c.updatedAt.toISOString().slice(0, 10),
|
|
}))
|
|
},
|
|
|
|
async ver_escrituracion() {
|
|
const filas = await prisma.leadCaptura.findMany({
|
|
where: { board: 'APARTADO', etapa: { in: [...FASES_ESCRITURACION] }, ...filtroPropio() },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 60,
|
|
})
|
|
const [nombres, unidades] = await Promise.all([nombrePorUsuarioId(), unidadPorApartadoId()])
|
|
return filas.map((c) => ({
|
|
cliente: c.nombreLead,
|
|
telefono: c.telefono ?? '',
|
|
unidad: unidades.get(c.leadId) ?? '',
|
|
estatus: c.etapa,
|
|
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
|
|
fechaUltimoCambio: c.updatedAt.toISOString().slice(0, 10),
|
|
}))
|
|
},
|
|
|
|
async ver_captura(args) {
|
|
const donde: Record<string, unknown> = {}
|
|
if (args.desarrollo?.trim()) donde['desarrollo'] = args.desarrollo.trim().toUpperCase()
|
|
if (args.unidad?.trim()) donde['unidadCodigo'] = args.unidad.trim()
|
|
if (Object.keys(donde).length === 0) {
|
|
return { error: 'Indica un desarrollo o una unidad para consultar la captura' }
|
|
}
|
|
// Los vendedores solo ven la captura del departamento de Ventas
|
|
if (!direccion) donde['departamento'] = 'VENTAS'
|
|
const filas = await prisma.capturaDepartamento.findMany({ where: donde, orderBy: [{ unidadCodigo: 'asc' }, { campo: 'asc' }] })
|
|
|
|
const porUnidad = new Map<string, Record<string, string | null>>()
|
|
for (const f of filas) {
|
|
const campos = porUnidad.get(f.unidadCodigo) ?? {}
|
|
campos[f.campo] = f.valor
|
|
porUnidad.set(f.unidadCodigo, campos)
|
|
}
|
|
const hoy = new Date().toISOString().slice(0, 10)
|
|
const tramitesObra = ['certificacion_gas', 'to_c4_fianza', 'regimen_inscrito', 'no_oficial', 'clave_catastral', 'listas_avaluo']
|
|
return [...porUnidad.entries()].slice(0, 40).map(([unidad, campos]) => {
|
|
const vencidos = tramitesObra
|
|
.filter((b) => {
|
|
const fecha = campos[`${b}_fecha`]
|
|
return fecha != null && fecha < hoy && campos[b] !== 'true'
|
|
})
|
|
.map((b) => `${b}: fecha ${campos[`${b}_fecha`]}`)
|
|
return {
|
|
unidad,
|
|
desarrollo: filas.find((f) => f.unidadCodigo === unidad)?.desarrollo ?? '',
|
|
campos,
|
|
tramitesObraVencidos: vencidos,
|
|
}
|
|
})
|
|
},
|
|
|
|
async ver_bitacora(args) {
|
|
const contacto = (args.contacto ?? '').trim()
|
|
if (!contacto) return { error: 'Falta el nombre o teléfono del contacto' }
|
|
const filas = await prisma.leadCaptura.findMany({
|
|
where: {
|
|
...filtroPropio(),
|
|
OR: [{ nombreLead: { contains: contacto } }, { telefono: { contains: contacto } }],
|
|
},
|
|
take: 5,
|
|
})
|
|
if (filas.length === 0) return { error: `No se encontró ningún contacto con "${contacto}"` }
|
|
const leadIds = filas.map((f) => f.leadId)
|
|
const claves = filas.map((f) => calcularClienteClave(f.nombreLead, f.telefono))
|
|
const notas = await prisma.recordatorioVendedor.findMany({
|
|
where: { OR: [{ leadId: { in: leadIds } }, { clienteClave: { in: claves } }] },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: Math.min(Math.max(args.contacto ? 15 : 10, 1), 30),
|
|
})
|
|
const nombres = await nombrePorUsuarioId()
|
|
return {
|
|
contactos: filas.map((f) => ({ nombre: f.nombreLead, etapa: f.etapa, tablero: f.board, telefono: f.telefono ?? '' })),
|
|
notas: notas.map((n) => ({
|
|
fecha: n.createdAt.toISOString().slice(0, 10),
|
|
nota: n.titulo,
|
|
autor: nombres.get(n.usuarioId) ?? '(sin nombre)',
|
|
completada: n.completado,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async ver_calendario(args) {
|
|
const dias = Math.min(Math.max(args.dias ?? 7, 1), 60)
|
|
const hoy = new Date()
|
|
const fin = new Date(hoy.getTime() + dias * MS_DIA)
|
|
const filas = await prisma.actividadLocal.findMany({
|
|
where: {
|
|
estado: 'ABIERTA',
|
|
fechaVencimiento: { gte: new Date(hoy.toISOString().slice(0, 10) + 'T00:00:00.000Z'), lte: fin },
|
|
...(direccion || mio === undefined ? {} : { usuarioId: mio }),
|
|
},
|
|
orderBy: { fechaVencimiento: 'asc' },
|
|
take: 50,
|
|
})
|
|
const nombres = direccion ? await nombrePorUsuarioId() : null
|
|
return {
|
|
rango: `hoy a ${fin.toISOString().slice(0, 10)}`,
|
|
pendientes: filas.map((a) => ({
|
|
titulo: a.titulo,
|
|
tipo: a.tipo,
|
|
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : null,
|
|
descripcion: a.descripcion ?? undefined,
|
|
vendedor: nombres ? (nombres.get(a.usuarioId) ?? '') : undefined,
|
|
})),
|
|
}
|
|
},
|
|
|
|
async ver_soporte(args) {
|
|
const estado = (args.estado ?? 'ABIERTA').trim().toUpperCase()
|
|
const where: Record<string, unknown> = { estado }
|
|
if (args.departamento?.trim()) where['departamento'] = args.departamento.trim()
|
|
if (!direccion && mio !== undefined) where['usuarioId'] = mio
|
|
const filas = await prisma.solicitudSoporte.findMany({ where, orderBy: { createdAt: 'desc' }, take: 30 })
|
|
return filas.map((s) => ({
|
|
titulo: s.titulo,
|
|
departamento: s.departamento,
|
|
prioridad: s.prioridad,
|
|
estado: s.estado,
|
|
solicitante: s.solicitante || s.solicitanteEmail || '',
|
|
responsable: s.responsable ?? '',
|
|
fechaSolicitud: s.fechaSolicitud.toISOString().slice(0, 10),
|
|
fechaLimite: s.fechaLimite ? s.fechaLimite.toISOString().slice(0, 10) : null,
|
|
}))
|
|
},
|
|
|
|
async estadisticas(args) {
|
|
const dias = Math.min(Math.max(args.dias ?? 30, 1), 365)
|
|
const desde = new Date(Date.now() - dias * MS_DIA)
|
|
|
|
if (args.consulta === 'leads_nuevos') {
|
|
const filas = await prisma.leadCaptura.findMany({
|
|
where: { createdAt: { gte: desde }, ...filtroPropio() },
|
|
select: { usuarioId: true, board: true, etapa: true },
|
|
})
|
|
const leads = filas.filter((f) => f.board === 'LEADS')
|
|
if (!direccion) return { periodo: `${dias} días`, leadsNuevos: leads.length }
|
|
const nombres = await nombrePorUsuarioId()
|
|
const porVendedor: Record<string, number> = {}
|
|
for (const f of leads) {
|
|
const n = nombres.get(f.usuarioId) ?? '(sin asignar)'
|
|
porVendedor[n] = (porVendedor[n] ?? 0) + 1
|
|
}
|
|
return { periodo: `${dias} días`, totalLeads: leads.length, porVendedor }
|
|
}
|
|
|
|
if (args.consulta === 'pipeline') {
|
|
const filas = await prisma.leadCaptura.findMany({ where: filtroPropio(), select: { board: true, etapa: true } })
|
|
const conteo: Record<string, Record<string, number>> = {}
|
|
for (const f of filas) {
|
|
const tablero = f.board
|
|
const etapa = f.etapa.trim() === '' ? '(sin etapa)' : f.etapa
|
|
conteo[tablero] = conteo[tablero] ?? {}
|
|
conteo[tablero]![etapa] = (conteo[tablero]![etapa] ?? 0) + 1
|
|
}
|
|
return { total: filas.length, porTableroYEtapa: conteo }
|
|
}
|
|
|
|
if (args.consulta === 'calificados') {
|
|
const where: Record<string, unknown> = { board: 'OPORTUNIDADES', createdAt: { gte: desde }, ...filtroPropio() }
|
|
if (args.desarrollo?.trim()) where['desarrollo'] = args.desarrollo.trim().toUpperCase()
|
|
const filas = await prisma.leadCaptura.findMany({ where, select: { datosJson: true, desarrollo: true } })
|
|
let si = 0, no = 0, porConfirmar = 0, sinDato = 0
|
|
for (const f of filas) {
|
|
const califica = perfilPlano(f.datosJson)['califica'] ?? ''
|
|
if (califica.toLowerCase().startsWith('s')) si++
|
|
else if (califica.toLowerCase().startsWith('n')) no++
|
|
else if (califica.toLowerCase().startsWith('p')) porConfirmar++
|
|
else sinDato++
|
|
}
|
|
return { periodo: `${dias} días`, afluencias: filas.length, califican: si, noCalifican: no, porConfirmar, sinDato }
|
|
}
|
|
|
|
if (args.consulta === 'unidades_por_estatus') {
|
|
const where = args.desarrollo?.trim() ? { desarrollo: args.desarrollo.trim().toUpperCase() } : {}
|
|
const filas = await prisma.unidad.findMany({ where, select: { desarrollo: true, status: true, modelo: true } })
|
|
const porEstatus: Record<string, number> = {}
|
|
const porDesarrollo: Record<string, Record<string, number>> = {}
|
|
const modelos: Record<string, string[]> = {}
|
|
for (const f of filas) {
|
|
porEstatus[f.status] = (porEstatus[f.status] ?? 0) + 1
|
|
porDesarrollo[f.desarrollo] = porDesarrollo[f.desarrollo] ?? {}
|
|
porDesarrollo[f.desarrollo]![f.status] = (porDesarrollo[f.desarrollo]![f.status] ?? 0) + 1
|
|
if (f.modelo) {
|
|
modelos[f.desarrollo] = modelos[f.desarrollo] ?? []
|
|
if (!modelos[f.desarrollo]!.includes(f.modelo)) modelos[f.desarrollo]!.push(f.modelo)
|
|
}
|
|
}
|
|
return { total: filas.length, porEstatus, porDesarrollo, modelos }
|
|
}
|
|
|
|
if (args.consulta === 'cobranza_resumen') {
|
|
const planes = await prisma.planPago.findMany({
|
|
where: direccion || mio === undefined ? {} : { vendedorId: mio },
|
|
include: { pagos: true },
|
|
})
|
|
const hoy = new Date()
|
|
let saldo = 0, vencidos = 0, montoVencido = 0, vencenHoy = 0
|
|
for (const p of planes) {
|
|
for (const x of p.pagos) {
|
|
const falta = x.monto - x.montoPagado
|
|
if (falta > 0) saldo += falta
|
|
if (x.fechaVencimiento < hoy && falta > 0) {
|
|
vencidos++
|
|
montoVencido += falta
|
|
}
|
|
if (x.fechaVencimiento.toDateString() === hoy.toDateString() && falta > 0) vencenHoy++
|
|
}
|
|
}
|
|
return {
|
|
planes: planes.length,
|
|
saldoTotal: r2(saldo),
|
|
pagosVencidos: vencidos,
|
|
montoVencido: r2(montoVencido),
|
|
pagosQueVencenHoy: vencenHoy,
|
|
}
|
|
}
|
|
|
|
// ranking_vendedores (solo dirección)
|
|
if (!direccion) return { error: 'El ranking de vendedores solo está disponible para dirección' }
|
|
const [filas, usuarios] = await Promise.all([
|
|
prisma.leadCaptura.findMany({ select: { usuarioId: true, board: true, etapa: true, createdAt: true } }),
|
|
prisma.usuario.findMany({ where: { rol: 'vendedor', activo: true }, select: { id: true, nombre: true } }),
|
|
])
|
|
const ranking = usuarios.map((u) => {
|
|
const propios = filas.filter((f) => f.usuarioId === u.id)
|
|
return {
|
|
vendedor: u.nombre,
|
|
leads: propios.filter((f) => f.board === 'LEADS').length,
|
|
afluencias: propios.filter((f) => f.board === 'OPORTUNIDADES').length,
|
|
apartados: propios.filter((f) => f.board === 'APARTADO').length,
|
|
nuevos30dias: propios.filter((f) => f.createdAt >= desde).length,
|
|
}
|
|
}).sort((a, b) => b.apartados - a.apartados || b.leads - a.leads)
|
|
return { periodo30dias: `${dias} días`, ranking }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** 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 (typeof ejecutor !== 'function') return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` })
|
|
try {
|
|
return JSON.stringify(await (ejecutor as (a: Record<string, unknown>) => Promise<unknown>)(args))
|
|
} catch (err) {
|
|
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
}
|
|
|
|
// ---------- Índice de chunks en memoria (evita releer todo en cada mensaje) ----------
|
|
|
|
let cacheChunks: { count: number; datos: ChunkIndexado[] } | null = null
|
|
|
|
/** Invalida el índice en memoria (lo usa indexarDocumento al re-indexar) */
|
|
export function invalidarCacheChunks(): void {
|
|
cacheChunks = null
|
|
}
|
|
|
|
/**
|
|
* Chunks con embeddings listos para el ranking. Se cachean en memoria y se
|
|
* refrescan cuando cambia el número de chunks (reindexación) — antes se leía
|
|
* y parseaba la tabla completa en CADA mensaje.
|
|
*/
|
|
async function chunksIndexados(): Promise<ChunkIndexado[]> {
|
|
// El conteo es la clave de invalidación; si falla (mocks/DB sin count) se relee
|
|
let count: number | null = null
|
|
try {
|
|
count = await prisma.aIChunk.count()
|
|
} catch {
|
|
count = null
|
|
}
|
|
if (count !== null && cacheChunks !== null && cacheChunks.count === count) return cacheChunks.datos
|
|
const filas = await prisma.aIChunk.findMany({
|
|
include: { documento: { select: { titulo: true } } },
|
|
orderBy: [{ documentoId: 'asc' }, { orden: 'asc' }],
|
|
})
|
|
const datos = filas.map((c) => ({
|
|
titulo: c.documento.titulo,
|
|
texto: c.texto,
|
|
embedding: JSON.parse(c.embedding) as number[],
|
|
}))
|
|
if (count !== null) cacheChunks = { count, datos }
|
|
return datos
|
|
}
|
|
|
|
// ---------- 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[]
|
|
/** Herramientas de datos que se consultaron para responder */
|
|
herramientasUsadas: string[]
|
|
}
|
|
|
|
/** 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 interface OpcionesChat {
|
|
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
|
|
usuarioId?: string | null
|
|
/** Ejecutores de herramientas (default: inventario local + motor de cotización) */
|
|
ejecutores?: EjecutoresHerramientas
|
|
/** Usuario que pregunta (sub+rol): escopa las herramientas por rol */
|
|
contexto?: ContextoUsuario | undefined
|
|
/** Recibe el texto conforme llega (streaming del proveedor) */
|
|
onDelta?: (textoParcial: string) => void
|
|
}
|
|
|
|
/** Máximo de rondas de ejecución de herramientas por mensaje */
|
|
export const MAX_RONDAS_TOOLS = 6
|
|
|
|
/** Tope de caracteres por resultado de herramienta (evita reventar el contexto) */
|
|
export const MAX_TOOL_RESULT_CHARS = 4000
|
|
|
|
/**
|
|
* Loop de tool calling: si el modelo pide herramientas, se ejecutan y se hace
|
|
* otra llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después se
|
|
* fuerza el cierre sin herramientas). Devuelve el texto y las herramientas
|
|
* usadas. Sin chatConTools en el cliente, chat plano.
|
|
*/
|
|
async function responderConHerramientas(
|
|
mensajes: MensajeApi[],
|
|
client: NanClient,
|
|
ejecutores: EjecutoresHerramientas,
|
|
onDelta?: (textoParcial: string) => void,
|
|
): Promise<{ texto: string; usadas: string[] }> {
|
|
if (!client.chatConTools) {
|
|
// Cliente simple (tests legados): chat plano sin tools
|
|
const texto = await 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 ?? '',
|
|
})),
|
|
)
|
|
return { texto, usadas: [] }
|
|
}
|
|
|
|
let herramientas = HERRAMIENTAS
|
|
const usadas: string[] = []
|
|
for (let ronda = 0; ronda <= MAX_RONDAS_TOOLS; ronda++) {
|
|
const resp = await client.chatConTools(mensajes, herramientas, onDelta)
|
|
if (resp.toolCalls.length === 0) {
|
|
if (resp.contenido !== null) return { texto: resp.contenido, usadas }
|
|
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) {
|
|
usadas.push(call.nombre)
|
|
let resultado = await ejecutarToolCall(call, ejecutores)
|
|
if (resultado.length > MAX_TOOL_RESULT_CHARS) {
|
|
resultado = `${resultado.slice(0, MAX_TOOL_RESULT_CHARS)}…(resultado truncado)`
|
|
}
|
|
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')
|
|
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
|
|
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
|
|
// chat ajeno con todo su historial.
|
|
if (
|
|
conversacion &&
|
|
opciones.usuarioId &&
|
|
conversacion.usuarioId &&
|
|
conversacion.usuarioId !== opciones.usuarioId &&
|
|
!esAdminOManager(opciones.contexto?.rol)
|
|
) {
|
|
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
|
|
}
|
|
conversacion ??= await prisma.aIConversacion.create({
|
|
data: { usuarioId: opciones.usuarioId ?? null },
|
|
})
|
|
|
|
await prisma.aIMensaje.create({
|
|
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
|
|
})
|
|
|
|
// Recuperación: embedding de la consulta contra el índice en memoria
|
|
const [consultaEmb] = await client.embed([mensaje])
|
|
const chunks = await chunksIndexados()
|
|
const top = consultaEmb ? rankearChunks(chunks, 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: MensajeApi[] = [
|
|
{ role: 'system', content: construirSystemPrompt(top) },
|
|
...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })),
|
|
]
|
|
|
|
// Streaming: si el proveedor no emitió deltas, se manda el texto completo al final
|
|
let emitido = false
|
|
const onDelta = opciones.onDelta
|
|
? (texto: string) => {
|
|
emitido = true
|
|
opciones.onDelta!(texto)
|
|
}
|
|
: undefined
|
|
|
|
const { texto: respuesta, usadas } = await responderConHerramientas(
|
|
mensajes,
|
|
client,
|
|
opciones.ejecutores ?? crearEjecutoresHerramientas(undefined, opciones.contexto),
|
|
onDelta,
|
|
)
|
|
if (opciones.onDelta && !emitido) opciones.onDelta(respuesta)
|
|
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, herramientasUsadas: usadas }
|
|
}
|