From 9643f866cc838db737afa0987c40e7ee8e733820 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 17 Sep 2026 08:41:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(chat-frasa):=20acceso=20total=20a=20datos?= =?UTF-8?q?=20(13=20herramientas=20con=20escopo=20por=20rol),=20streaming?= =?UTF-8?q?=20SSE,=20cach=C3=A9=20de=20embeddings,=20markdown=20y=20manten?= =?UTF-8?q?imiento=20en=20Configuraciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../knowledge/flujo-operativo-sistema.md | 76 + api-gateway/package.json | 3 +- .../scripts/actualizar-catalogos-ia.js | 103 ++ api-gateway/src/controllers/ai.controller.ts | 135 +- api-gateway/src/routes/index.ts | 9 + api-gateway/src/services/ai.service.test.ts | 46 +- api-gateway/src/services/ai.service.ts | 920 ++++++++-- apps/agente-ia/package-lock.json | 1475 ++++++++++++++++- apps/agente-ia/package.json | 4 +- apps/agente-ia/src/components/Burbuja.tsx | 84 +- apps/agente-ia/src/lib/vocabulario.ts | 31 + apps/agente-ia/src/pages/ChatPage.tsx | 158 +- apps/agente-ia/src/services/api.ts | 96 ++ apps/agente-ia/src/types/index.ts | 4 + .../src/components/config/AsistenteIA.tsx | 124 ++ .../src/components/config/ConfigPage.tsx | 8 +- apps/landing/src/services/config.ts | 34 + 17 files changed, 3133 insertions(+), 177 deletions(-) create mode 100644 api-gateway/knowledge/flujo-operativo-sistema.md create mode 100644 api-gateway/scripts/actualizar-catalogos-ia.js create mode 100644 apps/landing/src/components/config/AsistenteIA.tsx diff --git a/api-gateway/knowledge/flujo-operativo-sistema.md b/api-gateway/knowledge/flujo-operativo-sistema.md new file mode 100644 index 0000000..dbc4430 --- /dev/null +++ b/api-gateway/knowledge/flujo-operativo-sistema.md @@ -0,0 +1,76 @@ +# Guía operativa del sistema Frasa (CRM360) + +Sistema interno de Frasa Desarrollos. Acceso: https://frasa.consultoria-as.com +Todo el trabajo vive en el sistema: ya no se usa Monday ni hojas de cálculo sueltas. + +## Módulos y para qué sirve cada uno + +- **Mi Día** (`/hoy`): centro de trabajo del vendedor. Pestañas: Mis leads, Mis oportunidades, + Mis apartados, Escrituración, Mi tablero. Cada card muestra los datos del contacto, el último + cambio (quién y cuándo) y los botones para moverlo de etapa. Al dar clic en el nombre se abre + la ficha con información, cita, tareas y bitácora. +- **Alta Afluencia** (`/client`, pestaña Afluencia): registro del prospecto con perfilamiento + (edad, estado civil, empleo, ocupación, rango salarial, crédito, economía, califica, + dependientes, para qué lo quiere). La afluencia entra como **Oportunidad** en etapa Afluencia. +- **Alta de Cliente** (`/client`): expediente completo por etapas que se guardan solas — datos + generales, domicilio, contacto, empleo, económicos, co-acreditado, referencias, vivienda, + crédito y documentos (INE obligatoria). Genera los PDFs del expediente. +- **Cotizador** (`/quote`): cuatro propuestas comerciales (bancario, infonavit/cofinavit, + contado y semi-contado) sobre el inventario vivo, con apartado configurable, acabados y + mensualidades diferibles. Genera el PDF de la propuesta elegida y la guarda ligada al cliente. +- **Clientes / Cobranza** (`/pay`): planes de pago, cobranza del día, registro de abonos, + recibos y estados de cuenta en PDF, mora configurable y notas por plan. +- **Tablero** (`/dashboards`): Mi tablero y Tablero comercial (KPIs, equipo, perfilamiento) + y **Reportes** (10 reportes descargables en Excel). +- **Contactos**: lista de todos los contactos con su ficha e historial. +- **Clientes maestro**: base histórica de clientes con toda su información. +- **Captura** (`/captura`): expediente por departamentos (Obra, Ventas, Escrituración, + Finanzas, Postventa) por unidad y desarrollo. En Obra, cada trámite (certificación de gas, + TO y C4-fianza, régimen inscrito, no. oficial, clave catastral, listas para avalúo) lleva + fecha meta y check; la fecha se pinta verde al completarse y roja si se vence sin completar. + Las fechas ya establecidas solo las puede cambiar un administrador. +- **Soporte**: solicitudes internas a cualquier departamento con prioridad, responsable y fecha límite. +- **Chat Frasa** (`/ai`): este asistente. +- **Configuraciones** (solo admin): roles y permisos por rol, alta de usuarios, auditoría, + sistema y mantenimiento del asistente. + +## Flujo completo de venta + +1. **Afluencia**: el asesor registra al prospecto (Alta Afluencia) o captura rápida de lead. + Se asigna a quien lo captura. +2. **Cita**: desde Mis oportunidades se agenda con fecha y hora; aparece en el calendario. +3. **Cotización**: se cotiza la unidad interesada y la propuesta queda ligada al cliente. +4. **Apartado**: al apartar, la unidad se **bloquea del inventario** (no se puede vender dos + veces) y el cliente pasa a Mis apartados con el pipeline Enkontrol: + Pago Apartado → Alta Enkontrol → Entrego documentación → Cita cliente → Contrato Enkontrol → + Firma cliente. +5. **Escrituración**: tras la firma del cliente, el expediente pasa a Escrituración: + Venta → Seguimiento → Integración → X Autorizar → Autorizado → X Escriturar → Escriturado → + Titulado. Cualquier etapa puede cancelarse (Cancelo) indicando el motivo. +6. **Cobranza**: los planes de enganche diferido se siguen en Clientes/Cobranza, con abonos, + mora y estados de cuenta. +7. **Postventa**: entrega y seguimiento (SAC) — se captura en el módulo de Captura. + +## Reglas importantes + +- **Notas y bitácora**: cada contacto guarda su historial completo; las notas siguen al cliente + en todas las etapas (lead → oportunidad → apartado → escrituración). +- **Descartes y cancelaciones**: siempre se pide el motivo (categoría y subcategoría del catálogo + MOTIVOS NO COMPRA: Timing, Cliente, Competencia, Financiamiento, Ubicación, Producto, Precio). +- **Asignación**: los leads nuevos se reparten por round-robin entre los vendedores activos, y + se pueden reasignar manualmente (solo administradores). +- **Permisos**: cada rol ve los módulos que le corresponden; los vendedores solo ven sus propios + contactos, dirección y administración ven todo. +- **Documentos**: en los apartados son obligatorios la captura del cotizador, la captura del pago + de apartado y la INE. + +## Preguntas frecuentes de operación + +- **No me aparece una unidad en el cotizador**: si está apartada o vendida aparece bloqueada; + solo se cotizan unidades DISPONIBLES. +- **El desarrollo SOLE / MUTUO**: SOLE tiene modelos Alba, Horizonte y Ocaso; MUTUO captura el + modelo en texto libre. +- **¿Dónde veo quién atendió a un cliente?**: en la ficha del contacto (bitácora y último cambio) + y en el reporte de contactos. +- **¿Cómo descargo información?**: en Tablero → Reportes hay 10 reportes con filtros de fecha, + desarrollo y vendedor, todos descargables a Excel. diff --git a/api-gateway/package.json b/api-gateway/package.json index aab68ac..7fcddee 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -16,7 +16,8 @@ "seed:usuarios": "tsx scripts/seed-usuarios.ts", "seed:config": "tsx scripts/seed-config.js", "seed:unidades": "tsx scripts/seed-unidades-locales.js", - "prisma:migrate": "prisma migrate dev" + "prisma:migrate": "prisma migrate dev", + "seed:catalogos": "node scripts/actualizar-catalogos-ia.js" }, "dependencies": { "@prisma/client": "^6.14.0", diff --git a/api-gateway/scripts/actualizar-catalogos-ia.js b/api-gateway/scripts/actualizar-catalogos-ia.js new file mode 100644 index 0000000..c88757d --- /dev/null +++ b/api-gateway/scripts/actualizar-catalogos-ia.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Regenera los catálogos de unidades de la base de conocimiento del asistente + * a partir del INVENTARIO VIVO (tabla Unidad), para que el chat nunca conteste + * con un catálogo viejo. + * + * Uso: node scripts/actualizar-catalogos-ia.js + * Requiere NAN_API_KEY (embeddings) en el .env del ecosistema. + * + * Idempotente: indexarDocumento re-embebe solo si el contenido cambió. + */ + +import { PrismaClient } from '@prisma/client' +import { readFileSync } from 'fs' +import { indexarDocumento } from '../dist/services/ai.service.js' + +const prisma = new PrismaClient() + +// Carga el .env del ecosistema (NAN_API_KEY / DATABASE_URL) si no vienen del entorno +for (const ruta of ['../.env', '../api-gateway/.env']) { + try { + const env = readFileSync(new URL(ruta, import.meta.url), 'utf8') + for (const linea of env.split('\n')) { + const m = linea.match(/^([A-Z_]+)=(.*)$/) + if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '') + } + } catch { /* ignorar */ } +} + +const fmtMoneda = (n) => `$${Math.round(n).toLocaleString('es-MX')}` + +/** Markdown del catálogo de un desarrollo: modelos, disponibilidad y precios */ +function catalogoDe(desarrollo, unidades) { + const porModelo = new Map() + for (const u of unidades) { + const modelo = u.modelo || '(sin modelo)' + const lista = porModelo.get(modelo) ?? [] + lista.push(u) + porModelo.set(modelo, lista) + } + const conteoEstatus = {} + for (const u of unidades) conteoEstatus[u.status] = (conteoEstatus[u.status] ?? 0) + 1 + + const lineas = [ + `# Catálogo de unidades — Desarrollo ${desarrollo}`, + '', + `Inventario actualizado desde el sistema (${unidades.length} unidades).`, + '', + '## Disponibilidad por estatus', + ...Object.entries(conteoEstatus).map(([estatus, n]) => `- ${estatus}: ${n} unidades`), + '', + '## Modelos y precios', + ] + for (const [modelo, lista] of [...porModelo.entries()].sort()) { + const disponibles = lista.filter((u) => u.status === 'DISPONIBLE') + const precios = lista.map((u) => u.precioLista).filter((p) => p > 0) + const min = precios.length > 0 ? Math.min(...precios) : 0 + const max = precios.length > 0 ? Math.max(...precios) : 0 + lineas.push( + `- **${modelo}**: ${lista.length} unidades (${disponibles.length} disponibles). ` + + (precios.length > 0 ? `Precio de lista ${fmtMoneda(min)} a ${fmtMoneda(max)}.` : 'Sin precio capturado.'), + ) + if (disponibles.length > 0) { + const codigos = disponibles.slice(0, 12).map((u) => u.codigo).join(', ') + lineas.push(` - Disponibles: ${codigos}${disponibles.length > 12 ? ` (y ${disponibles.length - 12} más)` : ''}`) + } + } + lineas.push( + '', + '## Notas para el asistente', + '- Los precios son de lista, sin acabados (cocina y closet se cotizan aparte).', + '- Para los números de una unidad concreta (enganche, mensualidades, pago final) usa la herramienta cotizar_unidad.', + '- Para disponibilidad exacta en este momento usa buscar_unidad o contar_unidades: este documento es un resumen.', + ) + return lineas.join('\n') +} + +async function main() { + const unidades = await prisma.unidad.findMany({ orderBy: [{ desarrollo: 'asc' }, { modelo: 'asc' }, { codigo: 'asc' }] }) + if (unidades.length === 0) { + console.error('No hay unidades en el inventario local; nada que regenerar.') + process.exit(1) + } + const porDesarrollo = new Map() + for (const u of unidades) { + const lista = porDesarrollo.get(u.desarrollo) ?? [] + lista.push(u) + porDesarrollo.set(u.desarrollo, lista) + } + + console.log(`Regenerando catálogos de ${porDesarrollo.size} desarrollo(s)…`) + for (const [desarrollo, lista] of [...porDesarrollo.entries()].sort()) { + const r = await indexarDocumento(`Catálogo de unidades — Desarrollo ${desarrollo}`, catalogoDe(desarrollo, lista)) + console.log(` ${r.estado.padEnd(12)} ${r.titulo} (${r.chunks} chunks)`) + } + console.log('Listo.') + await prisma.$disconnect() +} + +main().catch((err) => { + console.error('Falló:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/api-gateway/src/controllers/ai.controller.ts b/api-gateway/src/controllers/ai.controller.ts index 44f190c..8c70c8c 100644 --- a/api-gateway/src/controllers/ai.controller.ts +++ b/api-gateway/src/controllers/ai.controller.ts @@ -1,23 +1,66 @@ +import { readdir, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import type { Request, Response } from 'express' import { z } from 'zod' import { prisma } from '../services/prisma.js' import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js' -import { NotFoundError } from '../utils/errors.js' +import { AppError, NotFoundError } from '../utils/errors.js' const chatSchema = z.object({ sessionId: z.string().uuid().nullish(), mensaje: z.string().min(1).max(4000), }) +function contextoDe(req: Request): { sub: string; rol: string } | undefined { + const t = req.usuarioToken + return t ? { sub: t.sub, rol: t.rol } : undefined +} + /** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */ export async function chatIA(req: Request, res: Response): Promise { const body = chatSchema.parse(req.body) const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, { usuarioId: req.usuarioToken?.sub ?? null, + contexto: contextoDe(req), }) res.json(resultado) } +/** + * POST /api/ai/chat/stream — igual que /chat pero con streaming SSE: manda + * { delta } conforme el proveedor escribe y cierra con { done, sessionId, + * fuentes, herramientasUsadas }. + */ +export async function chatIAStream(req: Request, res: Response): Promise { + const body = chatSchema.parse(req.body) + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') + res.setHeader('Cache-Control', 'no-cache, no-transform') + res.setHeader('Connection', 'keep-alive') + res.flushHeaders?.() + + const enviar = (payload: unknown): void => { + res.write(`data: ${JSON.stringify(payload)}\n\n`) + } + + try { + const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, { + usuarioId: req.usuarioToken?.sub ?? null, + contexto: contextoDe(req), + onDelta: (texto) => enviar({ delta: texto }), + }) + enviar({ + done: true, + sessionId: resultado.sessionId, + fuentes: resultado.fuentes, + herramientasUsadas: resultado.herramientasUsadas, + }) + } catch (err) { + enviar({ error: err instanceof Error ? err.message : 'Error del asistente' }) + } finally { + res.end() + } +} + /** * Filtro "mis conversaciones": cada usuario solo ve las suyas; admin las ve * todas. Sin token (AUTH_ENABLED=false) se devuelve todo. @@ -29,20 +72,28 @@ export function filtroConversaciones( return { usuarioId: token.sub } } -/** GET /api/ai/conversations — listado con último mensaje como preview (solo las mías) */ +/** GET /api/ai/conversations — listado con título (primer mensaje) y último mensaje */ export async function listarConversaciones(req: Request, res: Response): Promise { const where = filtroConversaciones(req.usuarioToken) const conversaciones = await prisma.aIConversacion.findMany({ ...(where ? { where } : {}), orderBy: { createdAt: 'desc' }, - include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } }, + include: { + mensajes: { orderBy: { createdAt: 'asc' }, select: { contenido: true, rol: true } }, + }, }) res.json( - conversaciones.map((c) => ({ - id: c.id, - createdAt: c.createdAt, - ultimoMensaje: c.mensajes[0] ?? null, - })), + conversaciones.map((c) => { + const primerUsuario = c.mensajes.find((m) => m.rol === 'user') + const ultimo = c.mensajes[c.mensajes.length - 1] ?? null + return { + id: c.id, + createdAt: c.createdAt, + titulo: (primerUsuario?.contenido ?? 'Conversación sin mensajes').slice(0, 80), + ultimoMensaje: ultimo, + mensajes: c.mensajes.length, + } + }), ) } @@ -75,6 +126,19 @@ export async function obtenerConversacion(req: Request, res: Response): Promise< }) } +/** DELETE /api/ai/conversations/:id — borra la conversación propia (admin: cualquiera) */ +export async function eliminarConversacion(req: Request, res: Response): Promise { + const id = String(req.params['id']) + const conversacion = await prisma.aIConversacion.findUnique({ where: { id } }) + if (!conversacion) throw new NotFoundError('Conversación no encontrada') + const token = req.usuarioToken + if (token && token.rol !== 'admin' && conversacion.usuarioId !== token.sub) { + throw new AppError(403, 'Solo puedes borrar tus conversaciones') + } + await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes + res.json({ ok: true, id }) +} + const documentoSchema = z.object({ titulo: z.string().min(1).max(200), contenido: z.string().min(1), @@ -111,3 +175,58 @@ export async function eliminarDocumento(req: Request, res: Response): Promise { + let archivos: string[] + try { + archivos = (await readdir(KNOWLEDGE_DIR)).filter((f) => f.endsWith('.md')).sort() + } catch { + throw new AppError(500, 'No se encontró la carpeta de conocimiento del asistente') + } + const resultados: { titulo: string; estado: string; chunks: number }[] = [] + for (const archivo of archivos) { + const contenido = await readFile(`${KNOWLEDGE_DIR}/${archivo}`, 'utf8') + const h1 = contenido.split('\n').find((l) => l.startsWith('# ')) + const titulo = h1 ? h1.replace(/^#\s+/, '').trim() : archivo.replace(/\.md$/, '') + const r = await indexarDocumento(titulo, contenido) + resultados.push({ titulo: r.titulo, estado: r.estado, chunks: r.chunks }) + } + res.json({ + documentos: resultados.length, + creados: resultados.filter((r) => r.estado === 'creado').length, + actualizados: resultados.filter((r) => r.estado === 'actualizado').length, + sinCambios: resultados.filter((r) => r.estado === 'sin-cambios').length, + detalle: resultados, + }) +} + +/** GET /api/ai/estado — métricas de uso del asistente (admin) */ +export async function estadoAsistente(_req: Request, res: Response): Promise { + const [conversaciones, mensajes, documentos, chunks] = await Promise.all([ + prisma.aIConversacion.count(), + prisma.aIMensaje.count(), + prisma.aIDocumento.count(), + prisma.aIChunk.count(), + ]) + const ultimos = await prisma.aIMensaje.findMany({ + where: { rol: 'user' }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { contenido: true, createdAt: true }, + }) + res.json({ + conversaciones, + mensajes, + documentos, + chunks, + ultimasPreguntas: ultimos.map((m) => ({ pregunta: m.contenido.slice(0, 90), fecha: m.createdAt })), + }) +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index a49e524..a90ffcf 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -78,10 +78,14 @@ import { } from '../controllers/automatizaciones.controller.js' import { chatIA, + chatIAStream, + eliminarConversacion, eliminarDocumento, + estadoAsistente, listarConversaciones, listarDocumentosIA, obtenerConversacion, + reindexarConocimiento, subirDocumento, } from '../controllers/ai.controller.js' import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js' @@ -284,11 +288,16 @@ apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin) apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad) apiRouter.post('/ai/chat', chatIA) +apiRouter.post('/ai/chat/stream', chatIAStream) apiRouter.get('/ai/conversations', listarConversaciones) apiRouter.get('/ai/conversations/:id', obtenerConversacion) +apiRouter.delete('/ai/conversations/:id', eliminarConversacion) apiRouter.post('/ai/documents', subirDocumento) apiRouter.get('/ai/documents', listarDocumentosIA) apiRouter.delete('/ai/documents/:id', eliminarDocumento) +// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso +apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento) +apiRouter.get('/ai/estado', requireAdmin, estadoAsistente) // ── Auditoría (admin) ──────────────────────────────────────────────── apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint) diff --git a/api-gateway/src/services/ai.service.test.ts b/api-gateway/src/services/ai.service.test.ts index 6661882..a9d3596 100644 --- a/api-gateway/src/services/ai.service.test.ts +++ b/api-gateway/src/services/ai.service.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js' import { HERRAMIENTAS, + MAX_RONDAS_TOOLS, chat, construirSystemPrompt, crearEjecutoresHerramientas, @@ -169,8 +170,10 @@ describe('construirSystemPrompt', () => { { titulo: 'Catálogo DIAGONAL', texto: 'Modelo 1R desde $2,850,000', score: 0.9 }, { titulo: 'Proceso de venta', texto: 'Apartado de $20,000', score: 0.8 }, ]) - expect(prompt).toContain('asistente interno de Frasa Desarrollos') - expect(prompt).toContain('Responde SOLO con base en el CONTEXTO') + expect(prompt).toContain('Chat Frasa') + expect(prompt).toContain('Nunca inventes cifras') + expect(prompt).toContain('buscar_contactos') + expect(prompt).toContain('estadisticas') expect(prompt).toContain('[1] Fuente: Catálogo DIAGONAL') expect(prompt).toContain('[2] Fuente: Proceso de venta') expect(prompt).toContain('Modelo 1R desde $2,850,000') @@ -631,13 +634,15 @@ describe('chat con herramientas (tool calling)', () => { expect(roles).toEqual(['user', 'assistant']) }) - it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => { + it('agota las rondas de tools: la última llamada va sin herramientas', async () => { mocksPrismaChat() const toolCall = (id: string): RespuestaCruda => ({ contenido: null, toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }], }) - const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }]) + // El cliente pide herramientas SIEMPRE; el loop debe cortar en MAX_RONDAS_TOOLS + const respuestas = Array.from({ length: MAX_RONDAS_TOOLS + 1 }, (_, i) => toolCall(`c${i + 1}`)) + const client = clienteConTools(respuestas) const ejecutores = { async cotizar_unidad() { return {} @@ -659,11 +664,36 @@ describe('chat con herramientas (tool calling)', () => { }, } - const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores }) + // La ronda de cierre (MAX_RONDAS_TOOLS) va sin herramientas y el cliente + // devuelve texto plano ahí (la última respuesta simulada es un tool call, + // así que el loop lanza al no poder cerrar con texto) + await expect(chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })).rejects.toThrow( + /no cerró la respuesta/, + ) + expect(client.llamadas).toHaveLength(MAX_RONDAS_TOOLS + 1) + expect(client.llamadas[MAX_RONDAS_TOOLS]!.herramientas).toEqual([]) + }) - expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre - expect(client.llamadas[2]!.herramientas).toEqual([]) - expect(r.respuesta).toBe('cierre forzado') + it('cierra con texto cuando el modelo deja de pedir herramientas', async () => { + mocksPrismaChat() + const toolCall = (id: string): RespuestaCruda => ({ + contenido: null, + toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }], + }) + const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre', toolCalls: [] }]) + const r = await chat(null, 'búscame algo', client, { + ejecutores: { + cotizar_unidad: async () => ({}), + buscar_unidad: async () => [{ codigo: 'SOLANA-6' }], + buscar_leads: async () => [], + ver_cliente: async () => ({}), + ver_oportunidades: async () => [], + ver_cobranza: async () => ({}), + }, + }) + expect(client.llamadas).toHaveLength(3) + expect(r.respuesta).toBe('cierre') + expect(r.herramientasUsadas).toEqual(['buscar_unidad', 'buscar_unidad']) }) it('respuesta directa sin tools: una sola llamada', async () => { diff --git a/api-gateway/src/services/ai.service.ts b/api-gateway/src/services/ai.service.ts index 0f816a9..7fc868c 100644 --- a/api-gateway/src/services/ai.service.ts +++ b/api-gateway/src/services/ai.service.ts @@ -49,7 +49,11 @@ 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[]): Promise + chatConTools?( + mensajes: MensajeApi[], + herramientas: HerramientaDef[], + onDelta?: (textoParcial: string) => void, + ): Promise } interface NanOpciones { @@ -72,6 +76,59 @@ async function postJson(url: string, apiKey: string, body: unknown): Promise 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) { @@ -84,7 +141,7 @@ export function crearNanClient(opciones: NanOpciones): NanClient { if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta') return contenido }, - async chatConTools(mensajes, herramientas) { + async chatConTools(mensajes, herramientas, onDelta) { const body: Record = { model: opciones.chatModel, messages: mensajes, @@ -95,7 +152,26 @@ export function crearNanClient(opciones: NanOpciones): NanClient { body['tools'] = herramientas body['tool_choice'] = 'auto' } - const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, body)) as { + 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 @@ -238,29 +314,40 @@ export function construirSystemPrompt(contexto: ChunkRecuperado[]): string { .map((c, i) => `[${i + 1}] Fuente: ${c.titulo}\n${c.texto}`) .join('\n\n---\n\n') return [ - 'Eres el asistente interno de Frasa Desarrollos (desarrolladora de vivienda en Tijuana, B.C.).', - 'Ayudas al equipo de ventas, cobranza y administración respondiendo preguntas sobre el catálogo,', - 'las propuestas comerciales del cotizador, los procesos internos y las políticas de la empresa.', + '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.', '', - 'REGLAS:', - '- Responde SOLO con base en el CONTEXTO proporcionado abajo. No inventes datos, precios ni cifras.', - '- Si el contexto no es suficiente para responder, dilo claramente y sugiere a qué área acudir', - ' (Ventas, Crédito y Finanzas, Administración o Jurídico).', - '- Responde en español, de forma clara y concisa.', - '- Al final de tu respuesta cita las fuentes usadas con el formato: "Fuentes: , ".', + '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:', - '- Tienes herramientas para cotizar una unidad con el motor del cotizador y para buscar unidades', - ' disponibles. Úsalas cuando te pidan números o disponibilidad; NUNCA inventes precios ni cifras.', - '- Cuando uses cotizar_unidad, presenta enganche, mensualidades y pago final, y aclara que la', - ' cotización es ORIENTATIVA: el PDF oficial se genera en el cotizador (/quote/).', - '- Si la unidad no existe o no está DISPONIBLE, dilo claramente y ofrece buscar alternativas.', - '- Tienes acceso a la base de datos de clientes, leads y cobranza.', - '- Usa buscar_leads cuando te pidan información sobre un lead específico.', - '- Usa ver_cliente para obtener datos completos de un cliente.', - '- Usa ver_oportunidades para ver el pipeline de ventas.', - '- Usa ver_cobranza para información de pagos y adeudos.', - '- NUNCA inventes datos de clientes o pagos; usa siempre las herramientas.', + '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}` @@ -326,6 +413,7 @@ export async function indexarDocumento( }, }, }) + invalidarCacheChunks() return { titulo, estado: 'actualizado', chunks: textos.length } } @@ -343,13 +431,31 @@ export async function indexarDocumento( }, }, }) + invalidarCacheChunks() return { titulo, estado: 'creado', chunks: textos.length } } -// ---------- Herramientas (function calling): cotizar y buscar unidades ---------- +// ---------- 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 === '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', @@ -379,13 +485,15 @@ export const HERRAMIENTAS: HerramientaDef[] = [ function: { name: 'buscar_unidad', description: - 'Busca hasta 5 unidades DISPONIBLE que cumplan los filtros (desarrollo, modelo, precio máximo).', + '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, p. ej. SOLANA, DIAGONAL, ATELIER' }, + 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)' }, }, }, }, @@ -393,15 +501,35 @@ export const HERRAMIENTAS: HerramientaDef[] = [ { type: 'function', function: { - name: 'buscar_leads', + name: 'contar_unidades', description: - 'Busca leads por nombre, teléfono o email. Devuelve hasta 10 resultados con nombre, teléfono, email, estado, origen, desarrollo.', + '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: { - busqueda: { type: 'string', description: 'Texto de búsqueda (nombre, teléfono o email)' }, + 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)' }, }, - required: ['busqueda'], }, }, }, @@ -410,42 +538,130 @@ export const HERRAMIENTAS: HerramientaDef[] = [ function: { name: 'ver_cliente', description: - 'Muestra la información completa de un cliente: datos personales, propiedad, cotización, pagos. Requiere el nombre del cliente.', + '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)' }, - }, + properties: { nombre: { type: 'string', description: 'Nombre del cliente (búsqueda parcial)' } }, required: ['nombre'], }, }, }, - { - type: 'function', - function: { - name: 'ver_oportunidades', - description: - 'Lista las oportunidades activas con su etapa, cliente, desarrollo y teléfono. Opcionalmente filtra por etapa.', - parameters: { - type: 'object', - properties: { - etapa: { - type: 'string', - description: 'Filtrar por etapa: Afluencia, Agendo Cita, Cotizador, Aparto', - }, - }, - }, - }, - }, { type: 'function', function: { name: 'ver_cobranza', description: - 'Muestra el estado de cobranza: clientes con pagos vencidos, monto total adeudado, planes activos.', + '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: {}, + 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'], }, }, }, @@ -462,6 +678,19 @@ 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 { @@ -476,26 +705,104 @@ 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 - buscar_leads(args: ArgsBuscarLeads): Promise ver_cliente(args: ArgsVerCliente): Promise - ver_oportunidades(args: ArgsVerOportunidades): Promise - ver_cobranza(): 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) y cálculo con el motor de cotización portado de - * los Excel. + * 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() @@ -534,9 +841,11 @@ export function crearEjecutoresHerramientas( nota: 'Cotización orientativa; el PDF oficial se genera en el cotizador (/quote/).', } }, + async buscar_unidad(args) { const unidades = await buscarUnidades() - let lista = unidades.filter((u) => u.status === 'DISPONIBLE') + const 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() @@ -548,16 +857,36 @@ export function crearEjecutoresHerramientas( 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 } }, @@ -577,12 +906,46 @@ export function crearEjecutoresHerramientas( 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 } }, - }) + 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([ @@ -607,6 +970,7 @@ export function crearEjecutoresHerramientas( celular: cliente.celular, proyecto: cliente.proyecto, unidadCodigo: cliente.unidadCodigo, + perfil: perfilPlano(cliente.payloadJson), lead: leads.length > 0 ? { etapa: leads[0]!.etapa, @@ -624,14 +988,11 @@ export function crearEjecutoresHerramientas( })), } }, + async ver_oportunidades(args) { - const where: Record = { board: 'OPORTUNIDADES' } - if (args.etapa) where.etapa = args.etapa.trim() - const oportunidades = await prisma.leadCaptura.findMany({ - where, - take: 20, - orderBy: { createdAt: 'desc' }, - }) + 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, @@ -640,12 +1001,58 @@ export function crearEjecutoresHerramientas( email: o.email, })) }, - async ver_cobranza() { + + 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() + 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 < hoy && 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' } }), - prisma.planPago.count({ where: { status: 'CANCELADO' } }), + prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }), + prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }), prisma.pago.findMany({ - where: { fechaVencimiento: { lt: new Date() } }, + where: { fechaVencimiento: { lt: new Date() }, plan: alcance }, select: { monto: true, montoPagado: true, @@ -669,6 +1076,264 @@ export function crearEjecutoresHerramientas( })), } }, + + 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 } + }, } } @@ -684,7 +1349,7 @@ export async function ejecutarToolCall( return JSON.stringify({ error: `Argumentos inválidos para ${call.nombre}` }) } const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas] - if (!ejecutor) return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` }) + 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) { @@ -692,6 +1357,42 @@ export async function ejecutarToolCall( } } +// ---------- Í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 @@ -706,6 +1407,8 @@ 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 */ @@ -722,24 +1425,33 @@ export interface OpcionesChat { 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 = 2 +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 - * una segunda llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después - * se fuerza el cierre sin herramientas). Sin chatConTools en el cliente, chat plano. + * 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, -): Promise { + onDelta?: (textoParcial: string) => void, +): Promise<{ texto: string; usadas: string[] }> { if (!client.chatConTools) { // Cliente simple (tests legados): chat plano sin tools - return client.chat( + const texto = await client.chat( mensajes .filter((m) => m.role !== 'tool') .map((m) => ({ @@ -747,13 +1459,15 @@ async function responderConHerramientas( 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) + const resp = await client.chatConTools(mensajes, herramientas, onDelta) if (resp.toolCalls.length === 0) { - if (resp.contenido) return resp.contenido + 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 @@ -767,7 +1481,11 @@ async function responderConHerramientas( })), }) for (const call of resp.toolCalls) { - const resultado = await ejecutarToolCall(call, ejecutores) + 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 @@ -793,23 +1511,10 @@ export async function chat( data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje }, }) - // Recuperación: embedding de la consulta contra todos los chunks (base chica, coseno en JS) + // Recuperación: embedding de la consulta contra el índice en memoria const [consultaEmb] = await client.embed([mensaje]) - const chunksDb = await prisma.aIChunk.findMany({ - include: { documento: { select: { titulo: true } } }, - orderBy: [{ documentoId: 'asc' }, { orden: 'asc' }], - }) - const top = consultaEmb - ? rankearChunks( - chunksDb.map((c) => ({ - titulo: c.documento.titulo, - texto: c.texto, - embedding: JSON.parse(c.embedding) as number[], - })), - consultaEmb, - TOP_K, - ) - : [] + 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({ @@ -822,11 +1527,22 @@ export async function chat( ...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })), ] - const respuesta = await responderConHerramientas( + // 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(), + opciones.ejecutores ?? crearEjecutoresHerramientas(undefined, opciones.contexto), + onDelta, ) + if (opciones.onDelta && !emitido) opciones.onDelta(respuesta) const fuentes = dedupeFuentes(top) await prisma.aIMensaje.create({ @@ -838,5 +1554,5 @@ export async function chat( }, }) - return { sessionId: conversacion.id, respuesta, fuentes } + return { sessionId: conversacion.id, respuesta, fuentes, herramientasUsadas: usadas } } diff --git a/apps/agente-ia/package-lock.json b/apps/agente-ia/package-lock.json index e36139b..66fe0dc 100644 --- a/apps/agente-ia/package-lock.json +++ b/apps/agente-ia/package-lock.json @@ -12,7 +12,9 @@ "lucide-react": "^1.41.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-hot-toast": "^2.6.0" + "react-hot-toast": "^2.6.0", + "react-markdown": "^9.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1553,18 +1555,58 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1580,6 +1622,18 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1697,6 +1751,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.17", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz", @@ -1775,6 +1839,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1792,6 +1866,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1802,6 +1916,16 @@ "node": ">= 16" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1819,7 +1943,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1833,6 +1956,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1843,6 +1979,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1853,6 +1998,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.412", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", @@ -1933,6 +2091,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1953,6 +2133,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2012,6 +2198,118 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2328,6 +2626,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2364,11 +2672,853 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2400,6 +3550,31 @@ "node": ">=18" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2466,6 +3641,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -2504,6 +3689,33 @@ "react-dom": ">=16" } }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2514,6 +3726,72 @@ "node": ">=0.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.62.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", @@ -2593,6 +3871,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2607,6 +3895,38 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2689,6 +4009,26 @@ "node": ">=14.0.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2703,6 +4043,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", @@ -2734,6 +4161,34 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3928,6 +5383,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/apps/agente-ia/package.json b/apps/agente-ia/package.json index c90bf7f..8cdc3b0 100644 --- a/apps/agente-ia/package.json +++ b/apps/agente-ia/package.json @@ -14,7 +14,9 @@ "lucide-react": "^1.41.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-hot-toast": "^2.6.0" + "react-hot-toast": "^2.6.0", + "react-markdown": "^9.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/agente-ia/src/components/Burbuja.tsx b/apps/agente-ia/src/components/Burbuja.tsx index c097cf5..da89f15 100644 --- a/apps/agente-ia/src/components/Burbuja.tsx +++ b/apps/agente-ia/src/components/Burbuja.tsx @@ -1,26 +1,94 @@ +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' import type { Mensaje } from '../types' +/** Etiquetas legibles de las herramientas de datos que consulta el asistente */ +const ETIQUETA_HERRAMIENTA: Record = { + cotizar_unidad: 'cotizó una unidad', + buscar_unidad: 'buscó en el inventario', + contar_unidades: 'contó el inventario', + buscar_contactos: 'buscó contactos', + ver_cliente: 'consultó la ficha del cliente', + ver_cobranza: 'consultó cobranza', + ver_apartados: 'consultó apartados', + ver_escrituracion: 'consultó escrituración', + ver_captura: 'consultó la captura por departamentos', + ver_bitacora: 'consultó la bitácora', + ver_calendario: 'consultó el calendario', + ver_soporte: 'consultó soporte', + estadisticas: 'calculó estadísticas', +} + function Burbuja({ mensaje }: { mensaje: Mensaje }) { const esUsuario = mensaje.rol === 'user' + const herramientas = [...new Set(mensaje.herramientasUsadas ?? [])] return (
-
+
- {mensaje.contenido} + {esUsuario ? ( + mensaje.contenido + ) : ( +
+

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + strong: ({ children }) => {children}, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + code: ({ children }) => ( + {children} + ), + table: ({ children }) => ( +
+ {children}
+
+ ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), + a: ({ children, href }) => ( + + {children} + + ), + }} + > + {mensaje.contenido} +
+
+ )}
- {!esUsuario && mensaje.fuentes.length > 0 && ( -
+ + {!esUsuario && (herramientas.length > 0 || mensaje.fuentes.length > 0) && ( +
+ {herramientas.map((h) => ( + + ⚙ {ETIQUETA_HERRAMIENTA[h] ?? h} + + ))} {mensaje.fuentes.map((f) => ( {f.titulo} diff --git a/apps/agente-ia/src/lib/vocabulario.ts b/apps/agente-ia/src/lib/vocabulario.ts index 8e21392..324c660 100644 --- a/apps/agente-ia/src/lib/vocabulario.ts +++ b/apps/agente-ia/src/lib/vocabulario.ts @@ -31,3 +31,34 @@ export function preguntasSugeridas(fecha: Date = new Date()): string[] { return plantilla(desarrollo) }) } + +/** Preguntas sugeridas para vendedores: su día a día con clientes y unidades */ +const SUGERENCIAS_VENDEDOR = [ + '¿Qué unidades disponibles tengo en SOLANA?', + '¿Cuáles son mis citas de esta semana?', + '¿Cómo va la cobranza de mi cliente Ricardo Camacho?', + '¿Qué notas hay en la bitácora de Juan Pérez?', + 'Cotiza la unidad DIAGONAL U-101 con enganche', + '¿Cuántos leads nuevos capté este mes?', +] + +/** Preguntas sugeridas para administración y dirección: negocio completo */ +const SUGERENCIAS_DIRECCION = [ + '¿Cuántos leads nuevos hay esta semana y por vendedor?', + 'Dame el resumen de cobranza: adeudo, vencidos y cobranza de hoy', + '¿Cuántas unidades hay por estatus en cada desarrollo?', + '¿Qué clientes están en escrituración y en qué estatus?', + 'Muéstrame las cancelaciones del último mes con sus motivos', + '¿Qué solicitudes de soporte están abiertas?', + 'Ranking de vendedores por apartados y leads', +] + +/** Preguntas sugeridas según el rol de la sesión (vendedor vs dirección/administración) */ +export function preguntasSugeridasPorRol(rol: string | undefined, fecha: Date = new Date()): string[] { + const esDireccion = rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta' + const base = esDireccion ? SUGERENCIAS_DIRECCION : SUGERENCIAS_VENDEDOR + const dia = diaDelAnio(fecha) + // Rota el punto de partida para que no salgan siempre las mismas seis + const inicio = dia % base.length + return [...base.slice(inicio), ...base.slice(0, inicio)].slice(0, 6) +} diff --git a/apps/agente-ia/src/pages/ChatPage.tsx b/apps/agente-ia/src/pages/ChatPage.tsx index 0a539de..b55f5a8 100644 --- a/apps/agente-ia/src/pages/ChatPage.tsx +++ b/apps/agente-ia/src/pages/ChatPage.tsx @@ -3,14 +3,16 @@ import Burbuja from '../components/Burbuja' import DarkModeToggle from '../components/DarkModeToggle' import toast from "react-hot-toast" import { - enviarMensaje, + eliminarConversacion, + enviarMensajeStream, esErrorDeRed, listarConversaciones, obtenerHistorial, type ConversacionResumen, } from '../services/api' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' -import { preguntasSugeridas } from '../lib/vocabulario' +import { preguntasSugeridasPorRol } from '../lib/vocabulario' +import { getSesion } from '../services/auth' import type { Mensaje } from '../types' let contadorLocal = 0 @@ -21,15 +23,19 @@ export default function ChatPage() { const [mensajes, setMensajes] = useState([]) const [entrada, setEntrada] = useState('') const [escribiendo, setEscribiendo] = useState(false) + // true cuando el asistente ya empezó a escribir (antes: consultando datos) + const [recibiendoTexto, setRecibiendoTexto] = useState(false) const [sinConexion, setSinConexion] = useState(false) - // Preguntas sugeridas del día: rotan por desarrollo (VOCABULARIO.DESARROLLOS) - const preguntasSugeridasHoy = useMemo(() => preguntasSugeridas(), []) + // Preguntas sugeridas según el rol de la sesión (vendedor vs dirección) + const rol = getSesion()?.usuario.rol + const preguntasSugeridasHoy = useMemo(() => preguntasSugeridasPorRol(rol), [rol]) // Texto del último envío fallido (habilita "Reintentar" junto al input) const [pendiente, setPendiente] = useState(null) // Panel de conversaciones previas const [panelAbierto, setPanelAbierto] = useState(false) const [conversaciones, setConversaciones] = useState(null) const [cargandoConversaciones, setCargandoConversaciones] = useState(false) + const [busquedaHistorial, setBusquedaHistorial] = useState('') const finalRef = useRef(null) // Al cargar: si hay sesión en localStorage, recupera su historial del API @@ -48,28 +54,50 @@ export default function ChatPage() { finalRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [mensajes, escribiendo]) - /** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */ + /** Envía el texto y va pintando la respuesta (streaming); en fallo deja `pendiente` */ async function transmitir(texto: string) { setEscribiendo(true) + setRecibiendoTexto(false) + const idRespuesta = idLocal() try { - const r = await enviarMensaje(sessionId, texto) + const r = await enviarMensajeStream(sessionId, texto, (delta) => { + setRecibiendoTexto(true) + setMensajes((prev) => { + const existe = prev.some((m) => m.id === idRespuesta) + if (!existe) { + return [...prev, { id: idRespuesta, rol: 'assistant', contenido: delta, fuentes: [] }] + } + return prev.map((m) => (m.id === idRespuesta ? { ...m, contenido: m.contenido + delta } : m)) + }) + }) setSessionId(r.sessionId) guardarSesion(r.sessionId) setSinConexion(false) setPendiente(null) - setMensajes((prev) => [ - ...prev, - { id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes }, - ]) + setMensajes((prev) => { + const completa: Mensaje = { + id: idRespuesta, + rol: 'assistant', + contenido: r.respuesta, + fuentes: r.fuentes, + herramientasUsadas: r.herramientasUsadas, + } + return prev.some((m) => m.id === idRespuesta) + ? prev.map((m) => (m.id === idRespuesta ? completa : m)) + : [...prev, completa] + }) } catch (err) { + // Quita la burbuja a medias para no dejar texto incompleto + setMensajes((prev) => prev.filter((m) => m.id !== idRespuesta)) if (esErrorDeRed(err)) { setSinConexion(true) } else { - toast.error('Error al enviar el mensaje') + toast.error(err instanceof Error && err.message ? err.message : 'Error al enviar el mensaje') } setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar } finally { setEscribiendo(false) + setRecibiendoTexto(false) } } @@ -122,6 +150,29 @@ export default function ChatPage() { } } + /** Borra una conversación del historial (propia) */ + async function borrarConversacion(id: string, e: React.MouseEvent) { + e.stopPropagation() + try { + await eliminarConversacion(id) + setConversaciones((prev) => (prev ? prev.filter((c) => c.id !== id) : prev)) + if (id === sessionId) { + limpiarSesion() + setSessionId(null) + setMensajes([]) + } + toast.success('Conversación eliminada') + } catch { + toast.error('No se pudo eliminar la conversación') + } + } + + const conversacionesFiltradas = (conversaciones ?? []).filter((c) => { + const q = busquedaHistorial.trim().toLowerCase() + if (q === '') return true + return (c.titulo ?? '').toLowerCase().includes(q) || (c.ultimoMensaje?.contenido ?? '').toLowerCase().includes(q) + }) + return (
@@ -175,8 +226,8 @@ export default function ChatPage() { Asistente interno

- Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de - venta o las políticas de Frasa. + Pregúntame lo que necesites: clientes, leads, apartados, escrituración, cobranza, + inventario, captura de obra, citas o procesos de Frasa.

{preguntasSugeridasHoy.map((pregunta) => ( @@ -207,7 +258,7 @@ export default function ChatPage() { {escribiendo && (

- Escribiendo… + {recibiendoTexto ? 'Escribiendo…' : 'Consultando datos del sistema…'}

)} @@ -280,34 +331,57 @@ export default function ChatPage() { ) : !conversaciones || conversaciones.length === 0 ? (

Sin conversaciones previas.

) : ( -
    - {conversaciones.map((c) => ( -
  • - -
  • - ))} -
+ <> + setBusquedaHistorial(e.target.value)} + placeholder="Buscar en el historial…" + aria-label="Buscar en el historial" + className="mt-4 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-xs text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" + /> + {conversacionesFiltradas.length === 0 ? ( +

Sin resultados para esa búsqueda.

+ ) : ( +
    + {conversacionesFiltradas.map((c) => ( +
  • + + +
  • + ))} +
+ )} + )}
diff --git a/apps/agente-ia/src/services/api.ts b/apps/agente-ia/src/services/api.ts index 17ccb86..708b2b8 100644 --- a/apps/agente-ia/src/services/api.ts +++ b/apps/agente-ia/src/services/api.ts @@ -77,6 +77,8 @@ export async function obtenerHistorial(sessionId: string): Promise { export interface ConversacionResumen { id: string createdAt: string + /** Primer mensaje del usuario (título de la conversación) */ + titulo?: string ultimoMensaje: { rol: string; contenido: string } | null } @@ -84,3 +86,97 @@ export interface ConversacionResumen { export function listarConversaciones(): Promise { return request('/ai/conversations') } + +/** DELETE /ai/conversations/:id — borra una conversación propia */ +export function eliminarConversacion(id: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/ai/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export interface ResultadoStream { + sessionId: string + respuesta: string + fuentes: Mensaje['fuentes'] + herramientasUsadas: string[] +} + +/** + * POST /ai/chat/stream — envía el mensaje y va entregando el texto conforme el + * proveedor lo escribe (onDelta), devolviendo al final la respuesta completa con + * sesión, fuentes y herramientas usadas. Cae al endpoint sin streaming si el + * navegador o el servidor no soportan SSE. + */ +export async function enviarMensajeStream( + sessionId: string | null, + mensaje: string, + onDelta: (textoParcial: string) => void, +): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/ai/chat/stream`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, mensaje }), + }), + ) + } catch (err) { + // Sin red: se intenta el endpoint normal (deja que el caller reporte el fallo) + const simple = await enviarMensaje(sessionId, mensaje) + return { ...simple, herramientasUsadas: [] } + } + manejar401(res) + if (!res.ok || !res.body) { + const simple = await enviarMensaje(sessionId, mensaje) + return { ...simple, herramientasUsadas: [] } + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let respuesta = '' + let resultado: ResultadoStream | null = null + + 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 === '') continue + let evento: { + delta?: string + done?: boolean + sessionId?: string + fuentes?: Mensaje['fuentes'] + herramientasUsadas?: string[] + error?: string + } + try { + evento = JSON.parse(payload) as typeof evento + } catch { + continue + } + if (evento.error) throw new Error(evento.error) + if (typeof evento.delta === 'string') { + respuesta += evento.delta + onDelta(evento.delta) + } + if (evento.done === true && evento.sessionId) { + resultado = { + sessionId: evento.sessionId, + respuesta, + fuentes: evento.fuentes ?? [], + herramientasUsadas: evento.herramientasUsadas ?? [], + } + } + } + } + + if (!resultado) throw new Error('El asistente no completó la respuesta') + return resultado +} diff --git a/apps/agente-ia/src/types/index.ts b/apps/agente-ia/src/types/index.ts index 92039d5..d8c6623 100644 --- a/apps/agente-ia/src/types/index.ts +++ b/apps/agente-ia/src/types/index.ts @@ -8,16 +8,20 @@ export interface Mensaje { rol: 'user' | 'assistant' contenido: string fuentes: Fuente[] + /** Herramientas de datos consultadas (solo en respuestas en vivo) */ + herramientasUsadas?: string[] } export interface RespuestaChat { sessionId: string respuesta: string fuentes: Fuente[] + herramientasUsadas?: string[] } export interface ConversacionResumen { id: string createdAt: string + titulo?: string ultimoMensaje: { rol: string; contenido: string } | null } diff --git a/apps/landing/src/components/config/AsistenteIA.tsx b/apps/landing/src/components/config/AsistenteIA.tsx new file mode 100644 index 0000000..12353b0 --- /dev/null +++ b/apps/landing/src/components/config/AsistenteIA.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react' +import { getEstadoAsistente, reindexarConocimiento, type EstadoAsistente } from '../../services/config' + +/** + * Mantenimiento del Chat Frasa (asistente IA): uso registrado y re-indexación + * de la base de conocimiento (knowledge/*.md) sin entrar al servidor. + */ +export function AsistenteIA() { + const [estado, setEstado] = useState(null) + const [cargando, setCargando] = useState(true) + const [reindexando, setReindexando] = useState(false) + const [error, setError] = useState('') + const [aviso, setAviso] = useState('') + + const cargar = useCallback(async () => { + setCargando(true) + setError('') + try { + setEstado(await getEstadoAsistente()) + } catch { + setError('No se pudo cargar el estado del asistente.') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { void cargar() }, [cargar]) + + const reindexar = async () => { + setReindexando(true) + setError('') + setAviso('') + try { + const r = await reindexarConocimiento() + setAviso( + `Conocimiento actualizado: ${r.documentos} documento(s) — ${r.creados} nuevo(s), ` + + `${r.actualizados} actualizado(s), ${r.sinCambios} sin cambios.`, + ) + await cargar() + } catch (err) { + setError(err instanceof Error && err.message ? err.message : 'No se pudo reindexar el conocimiento.') + } finally { + setReindexando(false) + } + } + + if (cargando) { + return ( +
+

Cargando estado del asistente…

+
+ ) + } + + return ( +
+
+

Chat Frasa (asistente)

+

+ El asistente responde con los datos vivos del sistema (clientes, apartados, cobranza, inventario, + captura) y con los documentos de su base de conocimiento. +

+ {error &&

{error}

} + {aviso &&

{aviso}

} + {estado && ( +
+ {[ + { etiqueta: 'Conversaciones', valor: estado.conversaciones }, + { etiqueta: 'Mensajes', valor: estado.mensajes }, + { etiqueta: 'Documentos', valor: estado.documentos }, + { etiqueta: 'Fragmentos indexados', valor: estado.chunks }, + ].map((kpi) => ( +
+
{kpi.etiqueta}
+
{kpi.valor}
+
+ ))} +
+ )} +
+ +
+

Base de conocimiento

+

+ Re-indexa los documentos de knowledge/ + {' '}(guía operativa, procesos, catálogos y FAQ). Solo vuelve a generar los vectores de lo que cambió. + Después de actualizar inventario conviene correr también{' '} + npm run seed:catalogos en el servidor. +

+
+ + +
+
+ + {estado && estado.ultimasPreguntas.length > 0 && ( +
+

Últimas preguntas

+
    + {estado.ultimasPreguntas.map((p, i) => ( +
  • + {p.pregunta} + {p.fecha.slice(0, 10)} +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/apps/landing/src/components/config/ConfigPage.tsx b/apps/landing/src/components/config/ConfigPage.tsx index 9bbf53a..14f1cab 100644 --- a/apps/landing/src/components/config/ConfigPage.tsx +++ b/apps/landing/src/components/config/ConfigPage.tsx @@ -3,13 +3,14 @@ import { RolesPermisos } from './RolesPermisos' import { Usuarios } from './Usuarios' import { Sistema } from './Sistema' import { AuditoriaLog } from './AuditoriaLog' +import { AsistenteIA } from './AsistenteIA' import { getSesion } from '../../services/auth' interface Props { onVolver: () => void } -type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria' +type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria' const TABS_BASE: { key: Tab; label: string }[] = [ { key: 'roles', label: 'Roles y permisos' }, @@ -20,7 +21,9 @@ const TABS_BASE: { key: Tab; label: string }[] = [ export function ConfigPage({ onVolver }: Props) { const [tab, setTab] = useState('roles') const esAdmin = getSesion()?.usuario.rol === 'admin' - const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE + const tabs = esAdmin + ? [...TABS_BASE, { key: 'asistente' as const, label: 'Asistente IA' }, { key: 'auditoria' as const, label: 'Auditoría' }] + : TABS_BASE return (
@@ -63,6 +66,7 @@ export function ConfigPage({ onVolver }: Props) { {tab === 'roles' && } {tab === 'usuarios' && } {tab === 'sistema' && } + {tab === 'asistente' && esAdmin && } {tab === 'auditoria' && esAdmin && }
diff --git a/apps/landing/src/services/config.ts b/apps/landing/src/services/config.ts index 631297b..13f16c7 100644 --- a/apps/landing/src/services/config.ts +++ b/apps/landing/src/services/config.ts @@ -131,6 +131,40 @@ export async function crearUsuarioConfig(data: { return (await resp.json()) as UsuarioConfig } +// ─── Asistente IA (Chat Frasa) ───────────────────────────────── + +export interface EstadoAsistente { + conversaciones: number + mensajes: number + documentos: number + chunks: number + ultimasPreguntas: { pregunta: string; fecha: string }[] +} + +/** GET /ai/estado — métricas de uso del asistente (admin) */ +export async function getEstadoAsistente(): Promise { + const resp = await fetch(`${API_URL}/ai/estado`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as EstadoAsistente +} + +/** POST /ai/reindexar — re-indexa la base de conocimiento del asistente (admin) */ +export async function reindexarConocimiento(): Promise<{ + documentos: number + creados: number + actualizados: number + sinCambios: number +}> { + const resp = await fetch(`${API_URL}/ai/reindexar`, conAuth({ method: 'POST' })) + manejar401(resp) + if (!resp.ok) { + const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null + throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`) + } + return (await resp.json()) as { documentos: number; creados: number; actualizados: number; sinCambios: number } +} + // ─── Auditoría ───────────────────────────────────────────────── export interface AuditoriaItem {