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 } } export interface NanClient { chat(mensajes: MensajeChat[]): Promise embed(textos: string[]): Promise /** Chat con herramientas (function calling); opcional para compat con clientes simples */ chatConTools?( mensajes: MensajeApi[], herramientas: HerramientaDef[], onDelta?: (textoParcial: string) => void, ): Promise } interface NanOpciones { apiKey: string baseUrl: string chatModel: string embedModel: string } async function postJson(url: string, apiKey: string, body: unknown): Promise { 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 = { 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: , ".', '- 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 { 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 buscar_unidad(args: ArgsBuscarUnidad): Promise ver_cliente(args: ArgsVerCliente): Promise ver_cobranza(args?: ArgsVerCobranza): Promise /** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */ buscar_leads?(args: ArgsBuscarLeads): Promise /** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */ ver_oportunidades?(args: ArgsVerOportunidades): Promise contar_unidades?(args: ArgsContarUnidades): Promise buscar_contactos?(args: ArgsBuscarContactos): Promise ver_apartados?(args: { fase?: string }): Promise ver_escrituracion?(): Promise ver_captura?(args: ArgsVerCaptura): Promise ver_bitacora?(args: ArgsVerBitacora): Promise ver_calendario?(args: ArgsVerCalendario): Promise ver_soporte?(args: ArgsVerSoporte): Promise estadisticas?(args: ArgsEstadisticas): Promise } /** 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 { if (!datosJson) return {} try { const datos = JSON.parse(datosJson) as Record const plano: Record = {} 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 = () => 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 => (direccion || mio === undefined ? {} : { usuarioId: mio }) async function nombrePorUsuarioId(): Promise> { 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> { 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 = {} const porDesarrollo: Record = {} 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 = { ...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 = { 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 = { 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 = {} 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>() 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 = { 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 = {} 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> = {} 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 = { 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 = {} const porDesarrollo: Record> = {} const modelos: Record = {} 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 { let args: Record try { args = JSON.parse(call.argumentosJson || '{}') as Record } 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) => Promise)(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 { // 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() 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 { 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 } }