feat(ai): agente IA interno con RAG sobre NAN + app web en /ai
- API: /api/ai/chat, /conversations, /documents con embeddings qwen3-embedding, coseno en SQLite, dedup por hash, historial de conversaciones - Base de conocimiento semilla (6 docs, 36 chunks): catálogo DIAGONAL, propuestas del cotizador, créditos, proceso de venta, FAQ, privacidad - App agente-ia (Vite/React/Tailwind, StudioBlank) servida en /ai/ vía nginx - 21 tests nuevos (91/91 verdes)
This commit is contained in:
10
.env.example
10
.env.example
@@ -45,3 +45,13 @@ JWT_EXPIRES_IN="7d"
|
|||||||
# ============================================
|
# ============================================
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AGENTE IA (proveedor NAN, OpenAI-compatible)
|
||||||
|
# ============================================
|
||||||
|
NAN_API_KEY="PONER_API_KEY_DE_NAN"
|
||||||
|
NAN_BASE_URL="https://api.nan.builders/v1"
|
||||||
|
NAN_CHAT_MODEL="qwen3.6"
|
||||||
|
NAN_EMBED_MODEL="qwen3-embedding"
|
||||||
|
# Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||||
|
AI_MIN_SCORE="0.3"
|
||||||
|
|||||||
22
api-gateway/knowledge/aviso-privacidad-antilavado.md
Normal file
22
api-gateway/knowledge/aviso-privacidad-antilavado.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Aviso de privacidad y política antilavado (alto nivel)
|
||||||
|
|
||||||
|
## Aviso de privacidad
|
||||||
|
|
||||||
|
- Responsable del tratamiento de datos personales: **Frasa Desarrollos S.A.P.I. de C.V.**, con domicilio en Blvd. Fundadores No. 3078-E, Colonia Juárez, Tijuana, Baja California.
|
||||||
|
- Marco legal: Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) y su reglamento.
|
||||||
|
- Los datos personales y sensibles del cliente se tratan como información confidencial y solo se comparten con empresas del grupo Frasa Desarrollos, o con autoridades cuando así lo requiera la ley.
|
||||||
|
- Finalidades: gestiones para obtención de créditos, actualización de bases de datos, relación comercial, contractual y de servicios, y finalidades análogas; también campañas de publicidad y mercadotecnia.
|
||||||
|
- El titular puede ejercer sus derechos **ARCO** (acceso, rectificación, cancelación y oposición) y revocar su consentimiento escribiendo a info@frasa.com.mx o llamando al (664) 676 8100.
|
||||||
|
- El aviso de privacidad integral está disponible en www.frasa.com.mx/aviso-de-privacidad/ y puede actualizarse; el cliente debe consultarlo periódicamente.
|
||||||
|
|
||||||
|
## Prevención de lavado de dinero (LFPIORPI)
|
||||||
|
|
||||||
|
La compraventa de inmuebles es una actividad vulnerable conforme a la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita (LFPIORPI). Implicaciones prácticas para el equipo:
|
||||||
|
|
||||||
|
- **Identificación del cliente**: toda operación requiere el formato de identificación de clientes (persona física), con identificación oficial, comprobante de domicilio, RFC y CURP.
|
||||||
|
- **Beneficiario controlador**: cuando el comprador no es quien ejerce el beneficio o control final de la operación, debe llenarse además el formato de identificación del beneficiario controlador.
|
||||||
|
- **Límite de efectivo**: los pagos en efectivo del cliente no pueden sumar más de **$600,000 MXN** (o su equivalente en dólares) durante toda la vigencia del contrato de compraventa, conforme al artículo 32 fracción I de la LFPIORPI. Los pagos que superen ese límite deben realizarse con cheque de la cuenta personal del cliente o transferencia electrónica; los pagos en efectivo por encima del límite no se reciben.
|
||||||
|
- Los formatos antilavado forman parte del expediente básico y son requisito para el pago de comisiones.
|
||||||
|
- Declarar con falsedad en estos formatos tiene consecuencias penales (artículo 62 de la LFPIORPI); el cliente firma bajo protesta de decir verdad.
|
||||||
|
|
||||||
|
Ante cualquier operación inusual (pagos en efectivo cercanos al límite, terceros que pagan por el cliente, prisa injustificada), avisa a administración antes de aceptar recursos.
|
||||||
42
api-gateway/knowledge/catalogo-diagonal.md
Normal file
42
api-gateway/knowledge/catalogo-diagonal.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Catálogo de unidades — Desarrollo DIAGONAL
|
||||||
|
|
||||||
|
DIAGONAL es un desarrollo de departamentos de Frasa Desarrollos en Tijuana, B.C. El inventario actual consta de 38 unidades, todas con status DISPONIBLE, organizadas en tres niveles (series U-1xx, U-2xx y U-3xx). Las entregas están programadas entre octubre y noviembre de 2027.
|
||||||
|
|
||||||
|
## Modelos
|
||||||
|
|
||||||
|
- **1R** — 1 recámara. Superficie de construcción: 47.87 m². Estacionamiento: 14.31 m².
|
||||||
|
- **2RA** — 2 recámaras. Superficie de construcción: 69.485 m². Estacionamiento: 28.62 m².
|
||||||
|
- **2RB** — 2 recámaras. Superficie de construcción: 68.757 m². Estacionamiento: 28.62 m².
|
||||||
|
- **2RC** — 2 recámaras. Superficie de construcción: 72.328 m². Estacionamiento: 28.62 m².
|
||||||
|
|
||||||
|
El precio "con acabados" incluye cocina y clóset; el precio "sin acabados" es el precio base de la unidad.
|
||||||
|
|
||||||
|
## Precios de lista (MXN)
|
||||||
|
|
||||||
|
### Nivel 1 (U-101 a U-114) — entrega a partir del 8 de octubre de 2027
|
||||||
|
|
||||||
|
Las unidades de nivel 1 incluyen superficie excedente (terraza/balcón) de entre 9.13 m² y 21.34 m² según la unidad.
|
||||||
|
|
||||||
|
- 1R: $3,107,108.39 sin acabados / $3,269,000 con acabados.
|
||||||
|
- 2RA: $4,415,000 sin acabados / $4,579,000 con acabados.
|
||||||
|
|
||||||
|
### Nivel 2 (U-201 a U-214) — entregas del 15 de octubre al 12 de noviembre de 2027
|
||||||
|
|
||||||
|
Sin superficie excedente.
|
||||||
|
|
||||||
|
- 1R: $2,850,000 sin acabados / $2,950,000 con acabados.
|
||||||
|
- 2RA: $3,960,000 sin acabados / $4,127,000 con acabados.
|
||||||
|
|
||||||
|
### Nivel 3 (U-301 a U-310) — entregas del 12 al 26 de noviembre de 2027
|
||||||
|
|
||||||
|
Sin superficie excedente.
|
||||||
|
|
||||||
|
- 2RA: $4,150,000 sin acabados / $4,315,000 con acabados.
|
||||||
|
- 2RB: $4,120,000 sin acabados / $4,280,000 con acabados.
|
||||||
|
- 2RC: $4,300,000 sin acabados / $4,450,000 con acabados.
|
||||||
|
|
||||||
|
## Notas comerciales
|
||||||
|
|
||||||
|
- Los precios son de lista y pueden cambiar; confirma siempre el precio vigente en el cotizador antes de presentar cifras al cliente.
|
||||||
|
- El apartado estándar es de $20,000 MXN y se descuenta del enganche en todas las propuestas.
|
||||||
|
- La disponibilidad se actualiza conforme se apartan unidades; verifica el status antes de cotizar.
|
||||||
39
api-gateway/knowledge/faq-vendedores.md
Normal file
39
api-gateway/knowledge/faq-vendedores.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# FAQ para vendedores
|
||||||
|
|
||||||
|
## ¿Cuál es el apartado y cómo se aplica?
|
||||||
|
|
||||||
|
El apartado estándar es de $20,000 MXN. No es un cargo adicional: se descuenta del enganche en las 4 propuestas del cotizador.
|
||||||
|
|
||||||
|
## ¿Qué incluye el precio "con acabados"?
|
||||||
|
|
||||||
|
Incluye cocina y clóset. El precio "sin acabados" es el precio base de la unidad. La diferencia de precio entre ambos varía por modelo y nivel; consúltala en el catálogo del desarrollo.
|
||||||
|
|
||||||
|
## ¿Cuántas mensualidades se pagan en las propuestas con crédito?
|
||||||
|
|
||||||
|
Las mensualidades cubren el periodo de construcción: se calculan como los meses completos desde la fecha de la cotización hasta la fecha de entrega de la unidad. Por eso dos unidades con distinta fecha de entrega pueden tener distinto número de mensualidades aunque cuesten lo mismo.
|
||||||
|
|
||||||
|
## ¿Qué propuesta le conviene más al cliente?
|
||||||
|
|
||||||
|
Depende de su situación: Contado y Semi-contado tienen descuento por defecto (2% y 1% respectivamente) y menor total pagado, pero exigen mayor desembolso inicial. Bancario e Infonavit-Cofinavit requieren solo 2.5% de enganche sobre precio de lista y difieren la mayor parte al pago final contra entrega, que cubre el crédito. Usa el comparador del cotizador: destaca la propuesta con menor total pagado.
|
||||||
|
|
||||||
|
## ¿Los descuentos se pueden modificar?
|
||||||
|
|
||||||
|
Los valores por defecto son 0% (Bancario e Infonavit) y 2% / 1% (Contado / Semi-contado). Cualquier descuento adicional debe autorizarse con gerencia antes de presentarlo al cliente.
|
||||||
|
|
||||||
|
## ¿Qué pasa si el cliente se arrepiente después de apartar?
|
||||||
|
|
||||||
|
Tiene 15 días naturales para formalizar; si no lo hace, la unidad se libera. La devolución del apartado (o la resultante si hay penalización) se realiza en un máximo de 30 días naturales.
|
||||||
|
|
||||||
|
## ¿Cuándo se paga la comisión del asesor?
|
||||||
|
|
||||||
|
Cuando el expediente básico está completo y entregado a administración y finanzas: formatos generales y antilavado, contrato firmado en 3 tantos, carta compromiso, cotizador firmado, identificación oficial, comprobante de domicilio y copia de los recibos de pago. El plazo para entregarlo es de 48 horas después de realizada la venta.
|
||||||
|
|
||||||
|
## ¿El cliente puede pagar en efectivo?
|
||||||
|
|
||||||
|
Los pagos en efectivo no pueden sumar más de $600,000 MXN durante toda la vigencia del contrato (límite de la ley antilavado). Los montos superiores deben pagarse con cheque de la cuenta personal del cliente o transferencia electrónica.
|
||||||
|
|
||||||
|
## ¿Dónde dirijo dudas que este asistente no resuelva?
|
||||||
|
|
||||||
|
- Precios, disponibilidad y descuentos: gerencia de ventas.
|
||||||
|
- Créditos y comprobación de ingresos: ejecutivo de crédito in-house.
|
||||||
|
- Expedientes, contratos y escrituración: administración y jurídico.
|
||||||
32
api-gateway/knowledge/proceso-venta-apartado.md
Normal file
32
api-gateway/knowledge/proceso-venta-apartado.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Proceso de venta y apartado
|
||||||
|
|
||||||
|
## Apartado
|
||||||
|
|
||||||
|
1. El cliente elige unidad y se genera la cotización con las 4 propuestas (Bancario, Infonavit-Cofinavit, Contado y Semi-contado).
|
||||||
|
2. El cliente paga el apartado (por defecto $20,000 MXN) y firma la carta de apartado.
|
||||||
|
3. A partir del apartado, el cliente tiene un plazo de hasta **15 días naturales** para acudir a las oficinas, formalizar la compra y firmar el contrato, además de realizar los trámites correspondientes.
|
||||||
|
4. Si el cliente no continúa con el proceso dentro de ese plazo, el desarrollador puede disponer de la unidad apartada y comercializarla, sin responsabilidad alguna.
|
||||||
|
5. En caso de cancelación y solicitud de devolución (o de liberación de la unidad por vencimiento del plazo), la devolución de la cantidad entregada —o la resultante en caso de penalización— tiene un plazo máximo de **30 días naturales**.
|
||||||
|
|
||||||
|
## Formalización de la venta
|
||||||
|
|
||||||
|
La venta se considera realizada cuando se completa: pago de apartado + enganche + firma de contrato y carta compromiso. Una vez realizada, el asesor tiene un máximo de **48 horas** para entregar el expediente básico a administración y finanzas.
|
||||||
|
|
||||||
|
## Checklist del expediente básico
|
||||||
|
|
||||||
|
- Formatos generales y formatos de ley antilavado.
|
||||||
|
- Identificación oficial y su validación.
|
||||||
|
- CURP y RFC.
|
||||||
|
- Acta de nacimiento original (y acta de matrimonio, en su caso).
|
||||||
|
- Comprobante de domicilio.
|
||||||
|
- Cotizador firmado con el esquema de pago autorizado al cliente.
|
||||||
|
- Carta compromiso.
|
||||||
|
- Contrato original impreso y firmado en 3 tantos.
|
||||||
|
- Copia de los recibos de pago y sus comprobantes.
|
||||||
|
|
||||||
|
El expediente básico completo es requisito para hacer válido el **pago de la comisión** del asesor. El seguimiento a la integración del expediente hasta la escrituración es responsabilidad del asesor de ventas y de gerencia.
|
||||||
|
|
||||||
|
## Después de la venta
|
||||||
|
|
||||||
|
- Si el esquema incluye mensualidades durante construcción, se genera el plan de pagos y se da seguimiento en la app de cobranza.
|
||||||
|
- El saldo contra entrega (pago final) se liquida con el crédito hipotecario o con los recursos del cliente, según la propuesta elegida, al momento de la escrituración.
|
||||||
46
api-gateway/knowledge/propuestas-cotizador.md
Normal file
46
api-gateway/knowledge/propuestas-cotizador.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Las 4 propuestas del cotizador Frasa
|
||||||
|
|
||||||
|
El cotizador genera siempre 4 propuestas comerciales para la misma unidad. Todas parten del **precio de lista** (sin o con acabados, según lo que elija el cliente) y del **apartado** (por defecto $20,000 MXN), que ya se pagó y se descuenta del enganche.
|
||||||
|
|
||||||
|
Elementos comunes a las 4 propuestas:
|
||||||
|
|
||||||
|
- **Descuento**: fracción sobre el precio de lista. Valores por defecto: Bancario 0%, Infonavit-Cofinavit 0%, Contado 2%, Semi-contado 1%.
|
||||||
|
- **Monto de descuento** = precio de lista × descuento.
|
||||||
|
- **Precio final** = precio de lista − monto de descuento.
|
||||||
|
- **Número de mensualidades (N)**: meses completos desde hoy hasta la fecha de entrega de la unidad (mínimo 1). Las mensualidades se pagan durante la construcción.
|
||||||
|
|
||||||
|
## Propuesta 1 — Crédito Bancario
|
||||||
|
|
||||||
|
Para clientes que liquidarán con crédito hipotecario bancario a la entrega.
|
||||||
|
|
||||||
|
- Enganche = 2.5% del precio de **lista**, menos el apartado ya pagado.
|
||||||
|
- Pago final (contra entrega, cubierto por el crédito) = 90% del precio final con descuento.
|
||||||
|
- Mensualidades = precio final − apartado − enganche − pago final, dividido entre N.
|
||||||
|
|
||||||
|
## Propuesta 2 — Crédito Infonavit / Cofinavit
|
||||||
|
|
||||||
|
Para clientes que liquidarán con crédito Infonavit o Cofinavit (Infonavit + banco) a la entrega.
|
||||||
|
|
||||||
|
- Enganche = 2.5% del precio de **lista**, menos el apartado ya pagado.
|
||||||
|
- Pago final (contra entrega, cubierto por el crédito) = 95% del precio final con descuento.
|
||||||
|
- Mensualidades = precio de **lista** − pago final − apartado − enganche, dividido entre N. (Ojo: el Excel del cotizador usa aquí el precio de lista, no el precio final; así está validado y así se calcula.)
|
||||||
|
|
||||||
|
## Propuesta 3 — Contado
|
||||||
|
|
||||||
|
Sin mensualidades: el cliente paga casi todo de inmediato.
|
||||||
|
|
||||||
|
- Enganche = 90% del precio final con descuento, menos el apartado ya pagado.
|
||||||
|
- Pago final = 10% del precio final con descuento.
|
||||||
|
- Mensualidad: no aplica (N/A).
|
||||||
|
|
||||||
|
## Propuesta 4 — Semi-contado
|
||||||
|
|
||||||
|
Esquema 50 / 40 / 10 sobre el precio final con descuento.
|
||||||
|
|
||||||
|
- Enganche = 50% del precio final, menos el apartado ya pagado.
|
||||||
|
- Mensualidades = 40% del precio final, dividido entre N.
|
||||||
|
- Pago final = 10% del precio final.
|
||||||
|
|
||||||
|
## Comparación
|
||||||
|
|
||||||
|
El total pagado por el cliente en cualquier propuesta es: apartado + enganche + total de mensualidades + pago final. El cotizador destaca la propuesta con menor total pagado; en empate gana la primera (Bancario).
|
||||||
37
api-gateway/knowledge/requisitos-creditos.md
Normal file
37
api-gateway/knowledge/requisitos-creditos.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Requisitos de créditos hipotecarios (orientativo)
|
||||||
|
|
||||||
|
Contenido general de México, con fines orientativos para el equipo de ventas. Los requisitos exactos los define cada institución y cambian con el tiempo; confirma siempre con el ejecutivo de crédito in-house de Frasa antes de prometer algo al cliente.
|
||||||
|
|
||||||
|
## Crédito Infonavit
|
||||||
|
|
||||||
|
- Ser derechohabiente con relación laboral vigente y cotizaciones al corriente.
|
||||||
|
- Tener los puntos mínimos exigidos por el Infonavit (precalificación en el portal del instituto con CURP y NSS).
|
||||||
|
- No tener otro crédito Infonavit vigente.
|
||||||
|
- Documentos habituales: identificación oficial, CURP, acta de nacimiento, comprobante de domicilio, estados de cuenta y constancia de semanas cotizadas.
|
||||||
|
|
||||||
|
## Cofinavit (Infonavit + banco)
|
||||||
|
|
||||||
|
- Combina el crédito Infonavit con un crédito bancario complementario cuando el monto Infonavit no alcanza.
|
||||||
|
- Se deben cumplir los requisitos de ambas instituciones; el banco evalúa además historial y capacidad de pago.
|
||||||
|
|
||||||
|
## Crédito Fovissste
|
||||||
|
|
||||||
|
- Para trabajadores del Estado con cotizaciones al Fovissste; la modalidad tradicional es por sorteo.
|
||||||
|
- Documentos habituales: identificación oficial, CURP, comprobante de domicilio, talones de pago y constancia de servicios.
|
||||||
|
|
||||||
|
## Crédito bancario tradicional
|
||||||
|
|
||||||
|
- Historial en buró de crédito sano (en general score alto y sin atrasos recientes).
|
||||||
|
- Comprobación de ingresos: empleados asalariados presentan carta de empleo, recibos de nómina de los últimos 3 meses y estados de cuenta donde se refleje la nómina.
|
||||||
|
- Enganche típico del 10% al 20% del valor de la vivienda, según el banco.
|
||||||
|
|
||||||
|
## Documentos que Frasa solicita para el expediente de crédito
|
||||||
|
|
||||||
|
- Solicitud de crédito hipotecario (en caso de Infonavit, la hoja 3 del formato).
|
||||||
|
- Autorización de consulta a buró de crédito.
|
||||||
|
- Identificación oficial vigente con fotografía y firma.
|
||||||
|
- Acta de nacimiento original (y acta de matrimonio, en su caso).
|
||||||
|
- Comprobante de domicilio reciente.
|
||||||
|
- Asalariados: carta original de empleo (papelería membretada, con puesto, antigüedad, NSS e ingresos brutos), 3 meses de recibos de nómina y 3 meses de estados de cuenta con abono de nómina.
|
||||||
|
- Personas físicas con actividad empresarial: alta en Hacienda, declaraciones anuales de los últimos 2 ejercicios y 6 meses de estados de cuenta.
|
||||||
|
- Personas con ingresos en EE.UU.: carta de empleo, formatos W2 y 1040 de un año, 3 meses de recibos de nómina, reporte de buró de EE.UU. con score superior a 680, 6 meses de estados de cuenta y copia del Social Security; si tiene actividad empresarial en EE.UU., además carta de actividad, formato 1099, permiso de operación vigente y 3 referencias.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"seed:ai": "tsx scripts/seed-ai.ts",
|
||||||
"prisma:migrate": "prisma migrate dev"
|
"prisma:migrate": "prisma migrate dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -129,3 +129,45 @@ model NotaPlan {
|
|||||||
|
|
||||||
@@index([planId])
|
@@index([planId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Agente IA interno (RAG) ----------
|
||||||
|
|
||||||
|
// Documento de la base de conocimiento; `hash` (sha256 del contenido) permite
|
||||||
|
// re-indexar solo cuando cambió.
|
||||||
|
model AIDocumento {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
titulo String @unique
|
||||||
|
contenido String
|
||||||
|
hash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
chunks AIChunk[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AIChunk {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
documentoId String
|
||||||
|
documento AIDocumento @relation(fields: [documentoId], references: [id], onDelete: Cascade)
|
||||||
|
texto String
|
||||||
|
embedding String // JSON array de floats (SQLite no tiene vector nativo)
|
||||||
|
orden Int
|
||||||
|
|
||||||
|
@@index([documentoId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AIConversacion {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
mensajes AIMensaje[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AIMensaje {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
conversacionId String
|
||||||
|
conversacion AIConversacion @relation(fields: [conversacionId], references: [id], onDelete: Cascade)
|
||||||
|
rol String // user | assistant
|
||||||
|
contenido String
|
||||||
|
fuentes String? // JSON [{titulo, score}] solo en mensajes del asistente
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([conversacionId])
|
||||||
|
}
|
||||||
|
|||||||
41
api-gateway/scripts/seed-ai.ts
Normal file
41
api-gateway/scripts/seed-ai.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Seed de la base de conocimiento del agente IA.
|
||||||
|
* Lee knowledge/*.md, usa el primer encabezado H1 como título y los indexa
|
||||||
|
* vía indexarDocumento (idempotente: solo re-embebe si cambió el hash).
|
||||||
|
*
|
||||||
|
* Uso: npm run seed:ai (requiere NAN_API_KEY en el .env del ecosistema)
|
||||||
|
*/
|
||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { indexarDocumento } from '../src/services/ai.service.js'
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.resolve(process.cwd(), 'knowledge')
|
||||||
|
|
||||||
|
function tituloDe(contenido: string, archivo: string): string {
|
||||||
|
const h1 = contenido.split('\n').find((l) => l.startsWith('# '))
|
||||||
|
return h1 ? h1.replace(/^#\s+/, '').trim() : archivo.replace(/\.md$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const archivos = (await readdir(KNOWLEDGE_DIR)).filter((f) => f.endsWith('.md')).sort()
|
||||||
|
if (archivos.length === 0) {
|
||||||
|
console.error(`No hay archivos .md en ${KNOWLEDGE_DIR}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(`Indexando ${archivos.length} documentos desde ${KNOWLEDGE_DIR}…`)
|
||||||
|
let totalChunks = 0
|
||||||
|
for (const archivo of archivos) {
|
||||||
|
const contenido = await readFile(path.join(KNOWLEDGE_DIR, archivo), 'utf8')
|
||||||
|
const titulo = tituloDe(contenido, archivo)
|
||||||
|
const r = await indexarDocumento(titulo, contenido)
|
||||||
|
totalChunks += r.chunks
|
||||||
|
console.log(` ${r.estado.padEnd(12)} ${titulo} (${r.chunks} chunks)`)
|
||||||
|
}
|
||||||
|
console.log(`Listo: ${archivos.length} documentos procesados.`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Falló el seed:', err instanceof Error ? err.message : err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -38,6 +38,15 @@ export const config = {
|
|||||||
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
||||||
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
||||||
},
|
},
|
||||||
|
ai: {
|
||||||
|
// Proveedor NAN (OpenAI-compatible): chat + embeddings
|
||||||
|
apiKey: env('NAN_API_KEY'),
|
||||||
|
baseUrl: env('NAN_BASE_URL', 'https://api.nan.builders/v1'),
|
||||||
|
chatModel: env('NAN_CHAT_MODEL', 'qwen3.6'),
|
||||||
|
embedModel: env('NAN_EMBED_MODEL', 'qwen3-embedding'),
|
||||||
|
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||||
|
minScore: Number(env('AI_MIN_SCORE', '0.3')),
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type AppConfig = typeof config
|
export type AppConfig = typeof config
|
||||||
|
|||||||
98
api-gateway/src/controllers/ai.controller.ts
Normal file
98
api-gateway/src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const chatSchema = z.object({
|
||||||
|
sessionId: z.string().uuid().nullish(),
|
||||||
|
mensaje: z.string().min(1).max(4000),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */
|
||||||
|
export async function chatIA(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = chatSchema.parse(req.body)
|
||||||
|
const resultado = await chat(body.sessionId ?? null, body.mensaje)
|
||||||
|
res.json(resultado)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai/conversations — listado con último mensaje como preview */
|
||||||
|
export async function listarConversaciones(_req: Request, res: Response): Promise<void> {
|
||||||
|
const conversaciones = await prisma.aIConversacion.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } },
|
||||||
|
})
|
||||||
|
res.json(
|
||||||
|
conversaciones.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
ultimoMensaje: c.mensajes[0] ?? null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFuentes(fuentes: string | null): FuenteCitada[] {
|
||||||
|
if (!fuentes) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(fuentes) as FuenteCitada[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai/conversations/:id — historial completo */
|
||||||
|
export async function obtenerConversacion(req: Request, res: Response): Promise<void> {
|
||||||
|
const conversacion = await prisma.aIConversacion.findUnique({
|
||||||
|
where: { id: String(req.params['id']) },
|
||||||
|
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
||||||
|
})
|
||||||
|
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
res.json({
|
||||||
|
id: conversacion.id,
|
||||||
|
createdAt: conversacion.createdAt,
|
||||||
|
mensajes: conversacion.mensajes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
rol: m.rol,
|
||||||
|
contenido: m.contenido,
|
||||||
|
fuentes: parseFuentes(m.fuentes),
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentoSchema = z.object({
|
||||||
|
titulo: z.string().min(1).max(200),
|
||||||
|
contenido: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/ai/documents — indexa o re-indexa (si cambió el hash) un documento */
|
||||||
|
export async function subirDocumento(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = documentoSchema.parse(req.body)
|
||||||
|
const resultado = await indexarDocumento(body.titulo, body.contenido)
|
||||||
|
res.status(resultado.estado === 'creado' ? 201 : 200).json(resultado)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai/documents — metadatos y conteo de chunks (sin contenido ni embeddings) */
|
||||||
|
export async function listarDocumentosIA(_req: Request, res: Response): Promise<void> {
|
||||||
|
const documentos = await prisma.aIDocumento.findMany({
|
||||||
|
orderBy: { titulo: 'asc' },
|
||||||
|
include: { chunks: { select: { id: true } } },
|
||||||
|
})
|
||||||
|
res.json(
|
||||||
|
documentos.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
titulo: d.titulo,
|
||||||
|
hash: d.hash,
|
||||||
|
chunks: d.chunks.length,
|
||||||
|
createdAt: d.createdAt,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/ai/documents/:id — borra el documento y sus chunks (cascade) */
|
||||||
|
export async function eliminarDocumento(req: Request, res: Response): Promise<void> {
|
||||||
|
const documento = await prisma.aIDocumento.findUnique({ where: { id: String(req.params['id']) } })
|
||||||
|
if (!documento) throw new NotFoundError('Documento no encontrado')
|
||||||
|
await prisma.aIDocumento.delete({ where: { id: documento.id } })
|
||||||
|
res.json({ ok: true, titulo: documento.titulo })
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
estadoAutomatizaciones,
|
estadoAutomatizaciones,
|
||||||
previewInactividad,
|
previewInactividad,
|
||||||
} from '../controllers/automatizaciones.controller.js'
|
} from '../controllers/automatizaciones.controller.js'
|
||||||
|
import {
|
||||||
|
chatIA,
|
||||||
|
eliminarDocumento,
|
||||||
|
listarConversaciones,
|
||||||
|
listarDocumentosIA,
|
||||||
|
obtenerConversacion,
|
||||||
|
subirDocumento,
|
||||||
|
} from '../controllers/ai.controller.js'
|
||||||
|
|
||||||
export const apiRouter = Router()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
@@ -58,3 +66,10 @@ apiRouter.post('/webhooks/monday', webhookMonday)
|
|||||||
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
||||||
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
||||||
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
||||||
|
|
||||||
|
apiRouter.post('/ai/chat', chatIA)
|
||||||
|
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||||
|
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||||
|
apiRouter.post('/ai/documents', subirDocumento)
|
||||||
|
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||||
|
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||||
|
|||||||
279
api-gateway/src/services/ai.service.test.ts
Normal file
279
api-gateway/src/services/ai.service.test.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { NanClient } from './ai.service.js'
|
||||||
|
import {
|
||||||
|
chat,
|
||||||
|
construirSystemPrompt,
|
||||||
|
decidirIndexacion,
|
||||||
|
dedupeFuentes,
|
||||||
|
fragmentar,
|
||||||
|
hashContenido,
|
||||||
|
indexarDocumento,
|
||||||
|
rankearChunks,
|
||||||
|
similitudCoseno,
|
||||||
|
} from './ai.service.js'
|
||||||
|
|
||||||
|
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
aIDocumento: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
aIChunk: {
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
aIConversacion: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
aIMensaje: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
/** Cliente NAN falso: embeddings deterministas por longitud y chat eco */
|
||||||
|
function clienteFalso(): NanClient & { llamadasEmbed: string[][] } {
|
||||||
|
const llamadasEmbed: string[][] = []
|
||||||
|
return {
|
||||||
|
llamadasEmbed,
|
||||||
|
async embed(textos: string[]) {
|
||||||
|
llamadasEmbed.push(textos)
|
||||||
|
return textos.map((t) => [t.length, t.length % 7, 1])
|
||||||
|
},
|
||||||
|
async chat() {
|
||||||
|
return 'Respuesta simulada del asistente.\n\nFuentes: Doc de prueba'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Chunking ----------
|
||||||
|
|
||||||
|
describe('fragmentar', () => {
|
||||||
|
it('devuelve un solo chunk para textos cortos', () => {
|
||||||
|
const chunks = fragmentar('Hola mundo.\n\nSegundo párrafo.')
|
||||||
|
expect(chunks).toHaveLength(1)
|
||||||
|
expect(chunks[0]).toContain('Hola mundo')
|
||||||
|
expect(chunks[0]).toContain('Segundo párrafo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('divide por párrafos respetando el tamaño máximo', () => {
|
||||||
|
const parrafos = Array.from({ length: 10 }, (_, i) => `Párrafo ${i}: ${'x'.repeat(100)}`)
|
||||||
|
const chunks = fragmentar(parrafos.join('\n\n'), 400, 50)
|
||||||
|
expect(chunks.length).toBeGreaterThan(2)
|
||||||
|
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(400 + 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aplica solape entre chunks consecutivos', () => {
|
||||||
|
const parrafos = Array.from({ length: 6 }, (_, i) => `P${i} ${'abcde'.repeat(30)}`)
|
||||||
|
const chunks = fragmentar(parrafos.join('\n\n'), 350, 60)
|
||||||
|
expect(chunks.length).toBeGreaterThan(1)
|
||||||
|
// el inicio de un chunk comparte caracteres con el final del anterior
|
||||||
|
const cola = chunks[0]!.slice(-30)
|
||||||
|
expect(chunks[1]!.includes(cola)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('corta duro un párrafo que excede el tamaño máximo sin perder texto', () => {
|
||||||
|
const largo = 'y'.repeat(1500)
|
||||||
|
const chunks = fragmentar(largo, 500, 100)
|
||||||
|
expect(chunks.length).toBeGreaterThanOrEqual(3)
|
||||||
|
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500)
|
||||||
|
// sin solape habría 1500 chars útiles; con solape el total crece pero nada se pierde
|
||||||
|
expect(chunks.join('').length).toBeGreaterThanOrEqual(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignora párrafos vacíos', () => {
|
||||||
|
expect(fragmentar('\n\n\n \n\n')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Coseno y ranking ----------
|
||||||
|
|
||||||
|
describe('similitudCoseno', () => {
|
||||||
|
it('es 1 para vectores idénticos y 0 para ortogonales', () => {
|
||||||
|
expect(similitudCoseno([1, 2, 3], [1, 2, 3])).toBeCloseTo(1)
|
||||||
|
expect(similitudCoseno([1, 0], [0, 1])).toBeCloseTo(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('es invariante a la escala', () => {
|
||||||
|
expect(similitudCoseno([1, 1], [5, 5])).toBeCloseTo(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve 0 si algún vector es cero', () => {
|
||||||
|
expect(similitudCoseno([0, 0], [1, 1])).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rankearChunks', () => {
|
||||||
|
const chunks = [
|
||||||
|
{ titulo: 'A', texto: 'a', embedding: [1, 0] },
|
||||||
|
{ titulo: 'B', texto: 'b', embedding: [0.9, 0.1] },
|
||||||
|
{ titulo: 'C', texto: 'c', embedding: [0, 1] },
|
||||||
|
{ titulo: 'D', texto: 'd', embedding: [0.8, 0.2] },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('ordena por score descendente y aplica top-k', () => {
|
||||||
|
const top = rankearChunks(chunks, [1, 0], 2, 0)
|
||||||
|
expect(top.map((t) => t.titulo)).toEqual(['A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filtra por el umbral mínimo', () => {
|
||||||
|
// coseno([1,0],[0.9,0.1]) ≈ 0.994 y coseno([1,0],[0.8,0.2]) ≈ 0.970
|
||||||
|
const top = rankearChunks(chunks, [1, 0], 4, 0.995)
|
||||||
|
expect(top.map((t) => t.titulo)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve vacío si nada supera el umbral', () => {
|
||||||
|
expect(rankearChunks(chunks, [0, 1], 4, 0.999)).toEqual([{ titulo: 'C', texto: 'c', score: 1 }])
|
||||||
|
expect(rankearChunks(chunks, [0, 1], 4, 1.0001)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- System prompt ----------
|
||||||
|
|
||||||
|
describe('construirSystemPrompt', () => {
|
||||||
|
it('incluye el contexto numerado con títulos de fuente y las reglas', () => {
|
||||||
|
const prompt = 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('[1] Fuente: Catálogo DIAGONAL')
|
||||||
|
expect(prompt).toContain('[2] Fuente: Proceso de venta')
|
||||||
|
expect(prompt).toContain('Modelo 1R desde $2,850,000')
|
||||||
|
expect(prompt).toContain('Fuentes:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marca explícitamente el contexto vacío', () => {
|
||||||
|
const prompt = construirSystemPrompt([])
|
||||||
|
expect(prompt).toContain('CONTEXTO: (vacío')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Dedup por hash ----------
|
||||||
|
|
||||||
|
describe('hashContenido / decidirIndexacion', () => {
|
||||||
|
it('el hash es estable y cambia con el contenido', () => {
|
||||||
|
expect(hashContenido('abc')).toBe(hashContenido('abc'))
|
||||||
|
expect(hashContenido('abc')).not.toBe(hashContenido('abd'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decide crear/actualizar/omitir según existencia y hash', () => {
|
||||||
|
expect(decidirIndexacion(null, 'h1')).toBe('crear')
|
||||||
|
expect(decidirIndexacion({ hash: 'h1' }, 'h1')).toBe('omitir')
|
||||||
|
expect(decidirIndexacion({ hash: 'h1' }, 'h2')).toBe('actualizar')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('indexarDocumento (dedup por hash)', () => {
|
||||||
|
it('crea un documento nuevo con sus chunks embebidos', async () => {
|
||||||
|
prismaMock.aIDocumento.findUnique.mockResolvedValue(null)
|
||||||
|
prismaMock.aIDocumento.create.mockResolvedValue({ id: 'doc-1' })
|
||||||
|
const client = clienteFalso()
|
||||||
|
|
||||||
|
const r = await indexarDocumento('Doc de prueba', 'Párrafo único de prueba.', client)
|
||||||
|
|
||||||
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'creado', chunks: 1 })
|
||||||
|
expect(client.llamadasEmbed).toEqual([['Párrafo único de prueba.']])
|
||||||
|
expect(prismaMock.aIDocumento.create).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omite la re-indexación si el hash no cambió (no llama embeddings)', async () => {
|
||||||
|
const contenido = 'Contenido sin cambios.'
|
||||||
|
prismaMock.aIDocumento.findUnique.mockResolvedValue({
|
||||||
|
id: 'doc-1',
|
||||||
|
hash: hashContenido(contenido),
|
||||||
|
chunks: [{ id: 'c1' }, { id: 'c2' }],
|
||||||
|
})
|
||||||
|
const client = clienteFalso()
|
||||||
|
|
||||||
|
const r = await indexarDocumento('Doc de prueba', contenido, client)
|
||||||
|
|
||||||
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'sin-cambios', chunks: 2 })
|
||||||
|
expect(client.llamadasEmbed).toHaveLength(0)
|
||||||
|
expect(prismaMock.aIDocumento.create).not.toHaveBeenCalled()
|
||||||
|
expect(prismaMock.aIDocumento.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-indexa borrando chunks viejos si el hash cambió', async () => {
|
||||||
|
prismaMock.aIDocumento.findUnique.mockResolvedValue({
|
||||||
|
id: 'doc-1',
|
||||||
|
hash: 'hash-viejo',
|
||||||
|
chunks: [{ id: 'c1' }],
|
||||||
|
})
|
||||||
|
prismaMock.aIDocumento.update.mockResolvedValue({ id: 'doc-1' })
|
||||||
|
const client = clienteFalso()
|
||||||
|
|
||||||
|
const r = await indexarDocumento('Doc de prueba', 'Contenido nuevo.', client)
|
||||||
|
|
||||||
|
expect(r).toEqual({ titulo: 'Doc de prueba', estado: 'actualizado', chunks: 1 })
|
||||||
|
expect(prismaMock.aIChunk.deleteMany).toHaveBeenCalledWith({ where: { documentoId: 'doc-1' } })
|
||||||
|
expect(prismaMock.aIDocumento.update).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Chat RAG ----------
|
||||||
|
|
||||||
|
describe('chat', () => {
|
||||||
|
it('crea conversación nueva, recupera contexto y persiste ambos mensajes', async () => {
|
||||||
|
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-1', createdAt: new Date() })
|
||||||
|
prismaMock.aIChunk.findMany.mockResolvedValue([
|
||||||
|
{ texto: 'Precios DIAGONAL', embedding: JSON.stringify([16, 2, 1]), documento: { titulo: 'Catálogo' } },
|
||||||
|
{ texto: 'Otro tema', embedding: JSON.stringify([1, 0, 500]), documento: { titulo: 'FAQ' } },
|
||||||
|
])
|
||||||
|
prismaMock.aIMensaje.findMany.mockResolvedValue([
|
||||||
|
{ rol: 'user', contenido: '¿precios?' },
|
||||||
|
])
|
||||||
|
prismaMock.aIMensaje.create.mockResolvedValue({})
|
||||||
|
const client = clienteFalso()
|
||||||
|
|
||||||
|
const r = await chat(null, '¿precios?', client)
|
||||||
|
|
||||||
|
expect(r.sessionId).toBe('conv-1')
|
||||||
|
expect(r.respuesta).toContain('Respuesta simulada')
|
||||||
|
// '¿precios?' tiene longitud 9 → embedding [9, 2, 1]; el chunk "Precios" [16,2,1] es el más cercano
|
||||||
|
expect(r.fuentes.length).toBeGreaterThan(0)
|
||||||
|
// se persisten el mensaje del usuario y el del asistente
|
||||||
|
expect(prismaMock.aIMensaje.create).toHaveBeenCalledTimes(2)
|
||||||
|
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
|
||||||
|
expect(roles).toEqual(['user', 'assistant'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reutiliza la conversación existente y falla con sessionId desconocido', async () => {
|
||||||
|
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce({ id: 'conv-9', createdAt: new Date() })
|
||||||
|
prismaMock.aIChunk.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.aIMensaje.findMany.mockResolvedValue([{ rol: 'user', contenido: 'hola' }])
|
||||||
|
prismaMock.aIMensaje.create.mockResolvedValue({})
|
||||||
|
const client = clienteFalso()
|
||||||
|
|
||||||
|
const r = await chat('conv-9', 'hola', client)
|
||||||
|
expect(r.sessionId).toBe('conv-9')
|
||||||
|
expect(prismaMock.aIConversacion.create).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
prismaMock.aIConversacion.findUnique.mockResolvedValueOnce(null)
|
||||||
|
await expect(chat('no-existe', 'hola', client)).rejects.toThrow('Conversación no encontrada')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dedupeFuentes', () => {
|
||||||
|
it('conserva el mejor score por título', () => {
|
||||||
|
const fuentes = dedupeFuentes([
|
||||||
|
{ titulo: 'A', texto: '', score: 0.5 },
|
||||||
|
{ titulo: 'A', texto: '', score: 0.91234 },
|
||||||
|
{ titulo: 'B', texto: '', score: 0.7 },
|
||||||
|
])
|
||||||
|
expect(fuentes).toEqual([
|
||||||
|
{ titulo: 'A', score: 0.912 },
|
||||||
|
{ titulo: 'B', score: 0.7 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
355
api-gateway/src/services/ai.service.ts
Normal file
355
api-gateway/src/services/ai.service.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { prisma } from './prisma.js'
|
||||||
|
import { config } from '../config/index.js'
|
||||||
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
|
||||||
|
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
|
||||||
|
|
||||||
|
export interface MensajeChat {
|
||||||
|
rol: 'system' | 'user' | 'assistant'
|
||||||
|
contenido: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NanClient {
|
||||||
|
chat(mensajes: MensajeChat[]): Promise<string>
|
||||||
|
embed(textos: string[]): Promise<number[][]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NanOpciones {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
chatModel: string
|
||||||
|
embedModel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url: string, apiKey: string, body: unknown): Promise<unknown> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detalle = await res.text().catch(() => '')
|
||||||
|
throw new AppError(502, `Proveedor IA respondió ${res.status}: ${detalle.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crearNanClient(opciones: NanOpciones): NanClient {
|
||||||
|
return {
|
||||||
|
async chat(mensajes) {
|
||||||
|
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, {
|
||||||
|
model: opciones.chatModel,
|
||||||
|
messages: mensajes.map((m) => ({ role: m.rol, content: m.contenido })),
|
||||||
|
temperature: 0.2,
|
||||||
|
})) as { choices?: { message?: { content?: string } }[] }
|
||||||
|
const contenido = data.choices?.[0]?.message?.content
|
||||||
|
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
|
||||||
|
return contenido
|
||||||
|
},
|
||||||
|
async embed(textos) {
|
||||||
|
if (textos.length === 0) return []
|
||||||
|
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
|
||||||
|
model: opciones.embedModel,
|
||||||
|
input: textos,
|
||||||
|
})) as { data?: { embedding?: number[] }[] }
|
||||||
|
const embeddings = (data.data ?? []).map((d) => d.embedding ?? [])
|
||||||
|
if (embeddings.length !== textos.length || embeddings.some((e) => e.length === 0)) {
|
||||||
|
throw new AppError(502, 'El proveedor IA no devolvió los embeddings esperados')
|
||||||
|
}
|
||||||
|
return embeddings
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton perezoso: no exige NAN_API_KEY en procesos que no usan IA
|
||||||
|
let clientePorDefecto: NanClient | null = null
|
||||||
|
function clienteNan(): NanClient {
|
||||||
|
clientePorDefecto ??= crearNanClient({
|
||||||
|
apiKey: config.ai.apiKey,
|
||||||
|
baseUrl: config.ai.baseUrl,
|
||||||
|
chatModel: config.ai.chatModel,
|
||||||
|
embedModel: config.ai.embedModel,
|
||||||
|
})
|
||||||
|
return clientePorDefecto
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Chunking (por párrafos, con solape) ----------
|
||||||
|
|
||||||
|
const TAMANO_CHUNK = 700
|
||||||
|
const SOLAPE_CHUNK = 120
|
||||||
|
|
||||||
|
/** Corta un texto largo en pedazos de ≤ tamanoMax con solape de caracteres */
|
||||||
|
function cortarDuro(texto: string, tamanoMax: number, solape: number): string[] {
|
||||||
|
const pedazos: string[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < texto.length) {
|
||||||
|
pedazos.push(texto.slice(i, i + tamanoMax))
|
||||||
|
i += tamanoMax - solape
|
||||||
|
}
|
||||||
|
return pedazos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragmenta un documento en chunks de ~tamanoMax caracteres respetando los
|
||||||
|
* párrafos (separados por líneas en blanco); un párrafo que excede el tamaño
|
||||||
|
* se corta duro. Cada chunk nuevo arranca con la cola del anterior (solape).
|
||||||
|
*/
|
||||||
|
export function fragmentar(texto: string, tamanoMax = TAMANO_CHUNK, solape = SOLAPE_CHUNK): string[] {
|
||||||
|
const parrafos = texto
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0)
|
||||||
|
const chunks: string[] = []
|
||||||
|
let actual = ''
|
||||||
|
const cerrar = () => {
|
||||||
|
if (actual.trim().length > 0) chunks.push(actual.trim())
|
||||||
|
actual = actual.slice(-solape)
|
||||||
|
}
|
||||||
|
for (const parrafo of parrafos) {
|
||||||
|
if (parrafo.length > tamanoMax) {
|
||||||
|
cerrar()
|
||||||
|
chunks.push(...cortarDuro(parrafo, tamanoMax, solape))
|
||||||
|
actual = (chunks[chunks.length - 1] ?? '').slice(-solape)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const candidato = actual.length > 0 ? `${actual}\n\n${parrafo}` : parrafo
|
||||||
|
if (candidato.length > tamanoMax && actual.trim().length > 0) {
|
||||||
|
cerrar()
|
||||||
|
actual = `${actual} ${parrafo}`
|
||||||
|
} else {
|
||||||
|
actual = candidato
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actual.trim().length > 0) chunks.push(actual.trim())
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Similitud coseno y ranking ----------
|
||||||
|
|
||||||
|
export function similitudCoseno(a: number[], b: number[]): number {
|
||||||
|
let dot = 0
|
||||||
|
let normA = 0
|
||||||
|
let normB = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const va = a[i] ?? 0
|
||||||
|
const vb = b[i] ?? 0
|
||||||
|
dot += va * vb
|
||||||
|
normA += va * va
|
||||||
|
normB += vb * vb
|
||||||
|
}
|
||||||
|
if (normA === 0 || normB === 0) return 0
|
||||||
|
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkRecuperado {
|
||||||
|
titulo: string
|
||||||
|
texto: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkIndexado {
|
||||||
|
titulo: string
|
||||||
|
texto: string
|
||||||
|
embedding: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-k por coseno, filtrado por umbral mínimo, ordenado de mayor a menor score */
|
||||||
|
export function rankearChunks(
|
||||||
|
chunks: ChunkIndexado[],
|
||||||
|
consulta: number[],
|
||||||
|
topK = 4,
|
||||||
|
minScore = config.ai.minScore,
|
||||||
|
): ChunkRecuperado[] {
|
||||||
|
return chunks
|
||||||
|
.map((c) => ({ titulo: c.titulo, texto: c.texto, score: similitudCoseno(consulta, c.embedding) }))
|
||||||
|
.filter((c) => c.score >= minScore)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, topK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Prompt ----------
|
||||||
|
|
||||||
|
export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
|
||||||
|
const bloques = contexto
|
||||||
|
.map((c, i) => `[${i + 1}] Fuente: ${c.titulo}\n${c.texto}`)
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
return [
|
||||||
|
'Eres 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.',
|
||||||
|
'',
|
||||||
|
'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: <título 1>, <título 2>".',
|
||||||
|
'',
|
||||||
|
contexto.length > 0
|
||||||
|
? `CONTEXTO:\n\n${bloques}`
|
||||||
|
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Indexación de documentos (dedup por hash) ----------
|
||||||
|
|
||||||
|
export function hashContenido(contenido: string): string {
|
||||||
|
return crypto.createHash('sha256').update(contenido, 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccionIndexacion = 'crear' | 'actualizar' | 'omitir'
|
||||||
|
|
||||||
|
/** Decide qué hacer con un documento según su hash: idempotente si no cambió */
|
||||||
|
export function decidirIndexacion(
|
||||||
|
existente: { hash: string } | null,
|
||||||
|
hashNuevo: string,
|
||||||
|
): AccionIndexacion {
|
||||||
|
if (!existente) return 'crear'
|
||||||
|
return existente.hash === hashNuevo ? 'omitir' : 'actualizar'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoIndexacion {
|
||||||
|
titulo: string
|
||||||
|
estado: 'creado' | 'actualizado' | 'sin-cambios'
|
||||||
|
chunks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indexa (o re-indexa si cambió el contenido) un documento de la base de conocimiento */
|
||||||
|
export async function indexarDocumento(
|
||||||
|
titulo: string,
|
||||||
|
contenido: string,
|
||||||
|
client: NanClient = clienteNan(),
|
||||||
|
): Promise<ResultadoIndexacion> {
|
||||||
|
const hash = hashContenido(contenido)
|
||||||
|
const existente = await prisma.aIDocumento.findUnique({
|
||||||
|
where: { titulo },
|
||||||
|
include: { chunks: { select: { id: true } } },
|
||||||
|
})
|
||||||
|
const accion = decidirIndexacion(existente, hash)
|
||||||
|
if (accion === 'omitir' && existente) {
|
||||||
|
return { titulo, estado: 'sin-cambios', chunks: existente.chunks.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
const textos = fragmentar(contenido)
|
||||||
|
const embeddings = await client.embed(textos)
|
||||||
|
|
||||||
|
if (accion === 'actualizar' && existente) {
|
||||||
|
await prisma.aIChunk.deleteMany({ where: { documentoId: existente.id } })
|
||||||
|
await prisma.aIDocumento.update({
|
||||||
|
where: { id: existente.id },
|
||||||
|
data: {
|
||||||
|
contenido,
|
||||||
|
hash,
|
||||||
|
chunks: {
|
||||||
|
create: textos.map((texto, orden) => ({
|
||||||
|
texto,
|
||||||
|
orden,
|
||||||
|
embedding: JSON.stringify(embeddings[orden] ?? []),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { titulo, estado: 'actualizado', chunks: textos.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aIDocumento.create({
|
||||||
|
data: {
|
||||||
|
titulo,
|
||||||
|
contenido,
|
||||||
|
hash,
|
||||||
|
chunks: {
|
||||||
|
create: textos.map((texto, orden) => ({
|
||||||
|
texto,
|
||||||
|
orden,
|
||||||
|
embedding: JSON.stringify(embeddings[orden] ?? []),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { titulo, estado: 'creado', chunks: textos.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Chat RAG ----------
|
||||||
|
|
||||||
|
const HISTORIAL_MAX = 10
|
||||||
|
const TOP_K = 4
|
||||||
|
|
||||||
|
export interface FuenteCitada {
|
||||||
|
titulo: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespuestaChat {
|
||||||
|
sessionId: string
|
||||||
|
respuesta: string
|
||||||
|
fuentes: FuenteCitada[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuentes únicas por título conservando el mejor score */
|
||||||
|
export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
|
||||||
|
const porTitulo = new Map<string, number>()
|
||||||
|
for (const c of chunks) {
|
||||||
|
porTitulo.set(c.titulo, Math.max(porTitulo.get(c.titulo) ?? 0, c.score))
|
||||||
|
}
|
||||||
|
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chat(
|
||||||
|
sessionId: string | null,
|
||||||
|
mensaje: string,
|
||||||
|
client: NanClient = clienteNan(),
|
||||||
|
): Promise<RespuestaChat> {
|
||||||
|
let conversacion = sessionId
|
||||||
|
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
||||||
|
: null
|
||||||
|
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
conversacion ??= await prisma.aIConversacion.create({ data: {} })
|
||||||
|
|
||||||
|
await prisma.aIMensaje.create({
|
||||||
|
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recuperación: embedding de la consulta contra todos los chunks (base chica, coseno en JS)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Historial: últimos N mensajes (incluye el que acabamos de persistir)
|
||||||
|
const historial = await prisma.aIMensaje.findMany({
|
||||||
|
where: { conversacionId: conversacion.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: -HISTORIAL_MAX,
|
||||||
|
})
|
||||||
|
const mensajes: MensajeChat[] = [
|
||||||
|
{ rol: 'system', contenido: construirSystemPrompt(top) },
|
||||||
|
...historial.map((m) => ({ rol: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), contenido: m.contenido })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const respuesta = await client.chat(mensajes)
|
||||||
|
const fuentes = dedupeFuentes(top)
|
||||||
|
|
||||||
|
await prisma.aIMensaje.create({
|
||||||
|
data: {
|
||||||
|
conversacionId: conversacion.id,
|
||||||
|
rol: 'assistant',
|
||||||
|
contenido: respuesta,
|
||||||
|
fuentes: JSON.stringify(fuentes),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sessionId: conversacion.id, respuesta, fuentes }
|
||||||
|
}
|
||||||
12
apps/agente-ia/index.html
Normal file
12
apps/agente-ia/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FRASA Desarrollos — Asistente IA</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3897
apps/agente-ia/package-lock.json
generated
Normal file
3897
apps/agente-ia/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/agente-ia/package.json
Normal file
27
apps/agente-ia/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "agente-ia",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/agente-ia/src/components/Burbuja.tsx
Normal file
33
apps/agente-ia/src/components/Burbuja.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Mensaje } from '../types'
|
||||||
|
|
||||||
|
function Burbuja({ mensaje }: { mensaje: Mensaje }) {
|
||||||
|
const esUsuario = mensaje.rol === 'user'
|
||||||
|
return (
|
||||||
|
<div className={`flex ${esUsuario ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[80%] ${esUsuario ? 'text-right' : 'text-left'}`}>
|
||||||
|
<div
|
||||||
|
className={`inline-block whitespace-pre-wrap px-4 py-3 text-sm leading-relaxed ${
|
||||||
|
esUsuario ? 'bg-accent text-white' : 'bg-neutral-100 text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mensaje.contenido}
|
||||||
|
</div>
|
||||||
|
{!esUsuario && mensaje.fuentes.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{mensaje.fuentes.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.titulo}
|
||||||
|
title={`Relevancia: ${(f.score * 100).toFixed(0)}%`}
|
||||||
|
className="border border-neutral-300 px-2 py-1 text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||||
|
>
|
||||||
|
{f.titulo}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Burbuja
|
||||||
23
apps/agente-ia/src/index.css
Normal file
23
apps/agente-ia/src/index.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--color-accent: #1B4332;
|
||||||
|
--radius-sm: 0;
|
||||||
|
--radius-md: 0;
|
||||||
|
--radius-lg: 0;
|
||||||
|
--radius-xl: 0;
|
||||||
|
--radius-2xl: 0;
|
||||||
|
--radius-3xl: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-white text-neutral-900 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #1B4332;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/agente-ia/src/lib/sesion.test.ts
Normal file
34
apps/agente-ia/src/lib/sesion.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { cargarSesion, guardarSesion, limpiarSesion, type StorageLike } from './sesion'
|
||||||
|
|
||||||
|
function storageFalso(): StorageLike & { datos: Map<string, string> } {
|
||||||
|
const datos = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
datos,
|
||||||
|
getItem: (k) => datos.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void datos.set(k, v),
|
||||||
|
removeItem: (k) => void datos.delete(k),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sesion del chat (localStorage)', () => {
|
||||||
|
it('devuelve null cuando no hay sesión guardada o está vacía', () => {
|
||||||
|
const s = storageFalso()
|
||||||
|
expect(cargarSesion(s)).toBeNull()
|
||||||
|
s.setItem('frasa-ai:sessionId', '')
|
||||||
|
expect(cargarSesion(s)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guarda y recupera el sessionId', () => {
|
||||||
|
const s = storageFalso()
|
||||||
|
guardarSesion('conv-123', s)
|
||||||
|
expect(cargarSesion(s)).toBe('conv-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limpiarSesion elimina el sessionId (nueva conversación)', () => {
|
||||||
|
const s = storageFalso()
|
||||||
|
guardarSesion('conv-123', s)
|
||||||
|
limpiarSesion(s)
|
||||||
|
expect(cargarSesion(s)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
22
apps/agente-ia/src/lib/sesion.ts
Normal file
22
apps/agente-ia/src/lib/sesion.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Sesión del chat persistida en localStorage: al recargar, la conversación sigue viva.
|
||||||
|
const CLAVE_SESION = 'frasa-ai:sessionId'
|
||||||
|
|
||||||
|
/** Subconjunto de Storage para poder probar sin DOM */
|
||||||
|
export interface StorageLike {
|
||||||
|
getItem(clave: string): string | null
|
||||||
|
setItem(clave: string, valor: string): void
|
||||||
|
removeItem(clave: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cargarSesion(storage: StorageLike = localStorage): string | null {
|
||||||
|
const id = storage.getItem(CLAVE_SESION)
|
||||||
|
return id && id.length > 0 ? id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guardarSesion(sessionId: string, storage: StorageLike = localStorage): void {
|
||||||
|
storage.setItem(CLAVE_SESION, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limpiarSesion(storage: StorageLike = localStorage): void {
|
||||||
|
storage.removeItem(CLAVE_SESION)
|
||||||
|
}
|
||||||
14
apps/agente-ia/src/main.tsx
Normal file
14
apps/agente-ia/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import '@fontsource/inter/300.css'
|
||||||
|
import '@fontsource/inter/400.css'
|
||||||
|
import '@fontsource/inter/500.css'
|
||||||
|
import '@fontsource/inter/700.css'
|
||||||
|
import './index.css'
|
||||||
|
import ChatPage from './pages/ChatPage'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ChatPage />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
156
apps/agente-ia/src/pages/ChatPage.tsx
Normal file
156
apps/agente-ia/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import Burbuja from '../components/Burbuja'
|
||||||
|
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
|
||||||
|
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||||
|
import type { Mensaje } from '../types'
|
||||||
|
|
||||||
|
let contadorLocal = 0
|
||||||
|
const idLocal = () => `local-${++contadorLocal}`
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
|
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
||||||
|
const [entrada, setEntrada] = useState('')
|
||||||
|
const [escribiendo, setEscribiendo] = useState(false)
|
||||||
|
const [sinConexion, setSinConexion] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const finalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
||||||
|
useEffect(() => {
|
||||||
|
const id = cargarSesion()
|
||||||
|
if (!id) return
|
||||||
|
obtenerHistorial(id)
|
||||||
|
.then((historial) => {
|
||||||
|
setSessionId(id)
|
||||||
|
setMensajes(historial)
|
||||||
|
})
|
||||||
|
.catch(() => limpiarSesion()) // sesión inválida o API caído: empezar limpio
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [mensajes, escribiendo])
|
||||||
|
|
||||||
|
async function enviar() {
|
||||||
|
const texto = entrada.trim()
|
||||||
|
if (!texto || escribiendo) return
|
||||||
|
setEntrada('')
|
||||||
|
setError(null)
|
||||||
|
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
|
||||||
|
setEscribiendo(true)
|
||||||
|
try {
|
||||||
|
const r = await enviarMensaje(sessionId, texto)
|
||||||
|
setSessionId(r.sessionId)
|
||||||
|
guardarSesion(r.sessionId)
|
||||||
|
setSinConexion(false)
|
||||||
|
setMensajes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
if (esErrorDeRed(err)) {
|
||||||
|
setSinConexion(true)
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
||||||
|
}
|
||||||
|
// el mensaje del usuario se queda visible; puede reintentar
|
||||||
|
} finally {
|
||||||
|
setEscribiendo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuevaConversacion() {
|
||||||
|
limpiarSesion()
|
||||||
|
setSessionId(null)
|
||||||
|
setMensajes([])
|
||||||
|
setError(null)
|
||||||
|
setSinConexion(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-white text-neutral-900">
|
||||||
|
<header className="border-b border-neutral-200">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4">
|
||||||
|
<span className="text-sm tracking-widest">
|
||||||
|
<span className="font-bold">FRASA</span>{' '}
|
||||||
|
<span className="font-light text-neutral-500">ASISTENTE IA</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nuevaConversacion}
|
||||||
|
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||||
|
>
|
||||||
|
Nueva conversación
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8">
|
||||||
|
{sinConexion && (
|
||||||
|
<p className="mb-6 text-xs text-amber-700">
|
||||||
|
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
|
||||||
|
nuevo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mb-6 text-xs text-red-700">{error}</p>}
|
||||||
|
|
||||||
|
{mensajes.length === 0 && !escribiendo ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Asistente interno
|
||||||
|
</p>
|
||||||
|
<p className="max-w-md text-2xl font-light text-neutral-900">
|
||||||
|
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta
|
||||||
|
o las políticas de Frasa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
|
{mensajes.map((m) => (
|
||||||
|
<Burbuja key={m.id} mensaje={m} />
|
||||||
|
))}
|
||||||
|
{escribiendo && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<p className="bg-neutral-100 px-4 py-3 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Escribiendo…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={finalRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-neutral-200">
|
||||||
|
<form
|
||||||
|
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void enviar()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="entrada-chat" className="sr-only">
|
||||||
|
Escribe tu mensaje
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entrada-chat"
|
||||||
|
type="text"
|
||||||
|
value={entrada}
|
||||||
|
onChange={(e) => setEntrada(e.target.value)}
|
||||||
|
placeholder="Escribe tu pregunta…"
|
||||||
|
autoComplete="off"
|
||||||
|
className="flex-1 border border-neutral-300 px-4 py-3 text-sm placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={escribiendo || entrada.trim().length === 0}
|
||||||
|
className="bg-accent px-6 py-3 text-xs font-bold uppercase tracking-widest text-white transition-opacity duration-150 hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
apps/agente-ia/src/services/api.ts
Normal file
69
apps/agente-ia/src/services/api.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Mensaje, RespuestaChat } from '../types'
|
||||||
|
|
||||||
|
// Cliente HTTP del API Gateway de FRASA (Express + Prisma)
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
|
// TODO produccion: '/api' same-origin detras de NGINX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
|
||||||
|
* NO es error de red: el servidor respondió y su respuesta manda.
|
||||||
|
*/
|
||||||
|
export class ErrorApi extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ErrorApi'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch solo rechaza (TypeError) cuando falla la red; una respuesta HTTP de
|
||||||
|
* error resuelve normalmente. El aviso ámbar solo aplica al primer caso.
|
||||||
|
*/
|
||||||
|
export function esErrorDeRed(err: unknown): boolean {
|
||||||
|
return err instanceof TypeError
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
|
||||||
|
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_URL}${ruta}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
||||||
|
try {
|
||||||
|
const cuerpo = (await res.json()) as { error?: string }
|
||||||
|
if (typeof cuerpo.error === 'string') mensaje = cuerpo.error
|
||||||
|
} catch {
|
||||||
|
// cuerpo no JSON: se queda el mensaje genérico
|
||||||
|
}
|
||||||
|
throw new ErrorApi(res.status, mensaje)
|
||||||
|
}
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enviarMensaje(sessionId: string | null, mensaje: string): Promise<RespuestaChat> {
|
||||||
|
return request<RespuestaChat>('/ai/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sessionId, mensaje }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversacionApi {
|
||||||
|
id: string
|
||||||
|
mensajes: { id: string; rol: string; contenido: string; fuentes: Mensaje['fuentes'] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recupera el historial de una conversación previa (al recargar con sessionId en localStorage) */
|
||||||
|
export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
|
||||||
|
const conv = await request<ConversacionApi>(`/ai/conversations/${sessionId}`)
|
||||||
|
return conv.mensajes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
rol: m.rol === 'assistant' ? 'assistant' : 'user',
|
||||||
|
contenido: m.contenido,
|
||||||
|
fuentes: m.fuentes ?? [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
23
apps/agente-ia/src/types/index.ts
Normal file
23
apps/agente-ia/src/types/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Fuente {
|
||||||
|
titulo: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mensaje {
|
||||||
|
id: string
|
||||||
|
rol: 'user' | 'assistant'
|
||||||
|
contenido: string
|
||||||
|
fuentes: Fuente[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespuestaChat {
|
||||||
|
sessionId: string
|
||||||
|
respuesta: string
|
||||||
|
fuentes: Fuente[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversacionResumen {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
ultimoMensaje: { rol: string; contenido: string } | null
|
||||||
|
}
|
||||||
22
apps/agente-ia/tsconfig.app.json
Normal file
22
apps/agente-ia/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/agente-ia/tsconfig.json
Normal file
7
apps/agente-ia/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
apps/agente-ia/tsconfig.node.json
Normal file
19
apps/agente-ia/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
9
apps/agente-ia/vite.config.ts
Normal file
9
apps/agente-ia/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// La app se sirve detrás de NGINX bajo el prefijo /ai
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/ai/',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
8
apps/agente-ia/vitest.config.ts
Normal file
8
apps/agente-ia/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// Config de tests separada para no mezclar los tipos de Vite y Vitest
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -31,6 +31,11 @@ server {
|
|||||||
try_files $uri $uri/ /pay/index.html;
|
try_files $uri $uri/ /pay/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ai/ {
|
||||||
|
alias /root/frasa-ecosystem/apps/agente-ia/dist/;
|
||||||
|
try_files $uri $uri/ /ai/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
# Landing (raíz)
|
# Landing (raíz)
|
||||||
location / {
|
location / {
|
||||||
root /root/frasa-ecosystem/apps/landing/dist;
|
root /root/frasa-ecosystem/apps/landing/dist;
|
||||||
|
|||||||
Reference in New Issue
Block a user