From 50ef3dfea03d19e3cecb5ee69e739a6e27309718 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 02:04:35 +0000 Subject: [PATCH] feat: add chat interface with NLP extraction module for Auteco advisors Implement integrated chat platform with: - Express.js backend with Socket.IO for real-time messaging - Evolution API webhook integration for WhatsApp connectivity - NLP extraction module (regex-based) for automatic client data detection: name, phone, cedula, email, profession, and Auteco motorcycle model - CRM sync endpoint for instant opportunity creation - React frontend with Context API: conversation list, chat panel, extracted data display, and inventory viewer - Docker Compose setup with PostgreSQL for deployment https://claude.ai/code/session_016N51btFTpiuaQEcjnnYpTk --- chat-nlp-module/.env.example | 21 + chat-nlp-module/.gitignore | 5 + chat-nlp-module/README.md | 103 +++++ chat-nlp-module/backend/Dockerfile | 14 + chat-nlp-module/backend/package.json | 22 + chat-nlp-module/backend/src/config.js | 30 ++ chat-nlp-module/backend/src/index.js | 53 +++ chat-nlp-module/backend/src/routes/crm.js | 62 +++ .../backend/src/routes/messages.js | 96 ++++ chat-nlp-module/backend/src/routes/webhook.js | 74 +++ .../backend/src/services/crmSync.js | 72 +++ .../backend/src/services/evolutionApi.js | 60 +++ .../backend/src/services/nlpExtractor.js | 200 +++++++++ chat-nlp-module/backend/src/utils/logger.js | 21 + chat-nlp-module/docker-compose.yml | 48 ++ chat-nlp-module/frontend/Dockerfile | 17 + chat-nlp-module/frontend/nginx.conf | 26 ++ chat-nlp-module/frontend/package.json | 21 + chat-nlp-module/frontend/public/index.html | 11 + chat-nlp-module/frontend/src/App.js | 30 ++ .../frontend/src/components/ChatPanel.jsx | 32 ++ .../frontend/src/components/ContactInfo.jsx | 24 + .../src/components/ConversationList.jsx | 42 ++ .../src/components/ExtractedDataPanel.jsx | 89 ++++ .../src/components/InventoryPanel.jsx | 76 ++++ .../frontend/src/components/MessageInput.jsx | 53 +++ .../frontend/src/components/MessageList.jsx | 36 ++ .../frontend/src/context/ChatContext.js | 157 +++++++ chat-nlp-module/frontend/src/index.js | 11 + chat-nlp-module/frontend/src/services/api.js | 50 +++ chat-nlp-module/frontend/src/styles/chat.css | 420 ++++++++++++++++++ 31 files changed, 1976 insertions(+) create mode 100644 chat-nlp-module/.env.example create mode 100644 chat-nlp-module/.gitignore create mode 100644 chat-nlp-module/README.md create mode 100644 chat-nlp-module/backend/Dockerfile create mode 100644 chat-nlp-module/backend/package.json create mode 100644 chat-nlp-module/backend/src/config.js create mode 100644 chat-nlp-module/backend/src/index.js create mode 100644 chat-nlp-module/backend/src/routes/crm.js create mode 100644 chat-nlp-module/backend/src/routes/messages.js create mode 100644 chat-nlp-module/backend/src/routes/webhook.js create mode 100644 chat-nlp-module/backend/src/services/crmSync.js create mode 100644 chat-nlp-module/backend/src/services/evolutionApi.js create mode 100644 chat-nlp-module/backend/src/services/nlpExtractor.js create mode 100644 chat-nlp-module/backend/src/utils/logger.js create mode 100644 chat-nlp-module/docker-compose.yml create mode 100644 chat-nlp-module/frontend/Dockerfile create mode 100644 chat-nlp-module/frontend/nginx.conf create mode 100644 chat-nlp-module/frontend/package.json create mode 100644 chat-nlp-module/frontend/public/index.html create mode 100644 chat-nlp-module/frontend/src/App.js create mode 100644 chat-nlp-module/frontend/src/components/ChatPanel.jsx create mode 100644 chat-nlp-module/frontend/src/components/ContactInfo.jsx create mode 100644 chat-nlp-module/frontend/src/components/ConversationList.jsx create mode 100644 chat-nlp-module/frontend/src/components/ExtractedDataPanel.jsx create mode 100644 chat-nlp-module/frontend/src/components/InventoryPanel.jsx create mode 100644 chat-nlp-module/frontend/src/components/MessageInput.jsx create mode 100644 chat-nlp-module/frontend/src/components/MessageList.jsx create mode 100644 chat-nlp-module/frontend/src/context/ChatContext.js create mode 100644 chat-nlp-module/frontend/src/index.js create mode 100644 chat-nlp-module/frontend/src/services/api.js create mode 100644 chat-nlp-module/frontend/src/styles/chat.css diff --git a/chat-nlp-module/.env.example b/chat-nlp-module/.env.example new file mode 100644 index 0000000..15c6374 --- /dev/null +++ b/chat-nlp-module/.env.example @@ -0,0 +1,21 @@ +# === Server === +PORT=3001 + +# === Evolution API === +EVOLUTION_API_URL=http://localhost:8080 +EVOLUTION_API_KEY=your-evolution-api-key +EVOLUTION_INSTANCE_NAME=asesores-chat + +# === Database (PostgreSQL) === +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=chat_nlp +DB_USER=postgres +DB_PASSWORD=changeme + +# === CRM API === +CRM_API_URL=http://localhost:8081 +CRM_API_KEY=your-crm-api-key + +# === Frontend === +FRONTEND_ORIGIN=http://localhost:3000 diff --git a/chat-nlp-module/.gitignore b/chat-nlp-module/.gitignore new file mode 100644 index 0000000..291f67a --- /dev/null +++ b/chat-nlp-module/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +logs/ +build/ +dist/ diff --git a/chat-nlp-module/README.md b/chat-nlp-module/README.md new file mode 100644 index 0000000..896bf30 --- /dev/null +++ b/chat-nlp-module/README.md @@ -0,0 +1,103 @@ +# Chat NLP Module - Plataforma de Asesores Auteco + +Interfaz de chat integrada con extracción automática de datos (NLP) para asesores de ventas de motocicletas Auteco. Conecta con WhatsApp a través de **Evolution API** y sincroniza oportunidades con el CRM. + +## Arquitectura + +``` +┌─────────────┐ ┌──────────────┐ ┌───────────────┐ +│ Frontend │◄───►│ Backend │◄───►│ Evolution API │◄──► WhatsApp +│ React SPA │ │ Express.js │ │ (webhook) │ +└─────────────┘ └──────┬───────┘ └───────────────┘ + │ + ┌──────┴───────┐ + │ NLP Module │ + │ (regex) │ + └──────┬───────┘ + │ + ┌──────────┴──────────┐ + │ │ + ┌──────┴──────┐ ┌───────┴───────┐ + │ PostgreSQL │ │ CRM API │ + │ / NocoDB │ │ (Oportunidades│ + └─────────────┘ └───────────────┘ +``` + +## Componentes + +### Backend (`/backend`) +- **Express.js** con Socket.IO para comunicación en tiempo real +- **Webhook handler** para eventos de Evolution API (mensajes entrantes/salientes) +- **NLP Extractor**: detección automática de nombre, teléfono, cédula, email, profesión y modelo de moto Auteco +- **CRM Sync**: creación de oportunidades con datos extraídos + +### Frontend (`/frontend`) +- **React** con Context API para estado global +- Panel de conversaciones (izquierda) +- Chat de mensajes tipo WhatsApp (centro) +- Panel de datos extraídos + inventario (derecha) +- Botón de acción rápida "Crear Oportunidad en CRM" + +### NLP - Datos Extraídos +| Campo | Ejemplo detectado | +|-------|-------------------| +| Nombre | "mi nombre es Juan Pérez" | +| Teléfono | "cel 3101234567", "+57 310 123 4567" | +| Cédula | "CC 1234567890", "cédula: 1.234.567.890" | +| Email | "juan@example.com" | +| Profesión | "soy ingeniero", "trabajo como contador" | +| Modelo moto | "Pulsar NS 200", "Duke 390", "Dominar 400" | + +## Inicio Rápido + +### Con Docker Compose +```bash +cd chat-nlp-module +cp .env.example .env +# Editar .env con credenciales reales +docker compose up --build +``` + +### Desarrollo Local +```bash +# Backend +cd backend +npm install +npm run dev + +# Frontend (otra terminal) +cd frontend +npm install +npm start +``` + +## Variables de Entorno + +Ver `.env.example` para la lista completa. Las principales: + +| Variable | Descripción | +|----------|-------------| +| `EVOLUTION_API_URL` | URL de la instancia de Evolution API | +| `EVOLUTION_API_KEY` | API key de Evolution | +| `EVOLUTION_INSTANCE_NAME` | Nombre de la instancia WhatsApp | +| `CRM_API_URL` | URL del CRM para crear oportunidades | +| `CRM_API_KEY` | API key del CRM | + +## Configuración Evolution API + +1. Crear instancia en Evolution API con nombre definido en `EVOLUTION_INSTANCE_NAME` +2. Configurar webhook apuntando a: `http://:3001/api/webhook/evolution` +3. Eventos requeridos: `messages.upsert`, `messages.update` + +## Endpoints API + +| Método | Ruta | Descripción | +|--------|------|-------------| +| POST | `/api/webhook/evolution` | Recibe eventos de Evolution API | +| POST | `/api/messages/send` | Envía mensaje de texto | +| POST | `/api/messages/send-media` | Envía archivo multimedia | +| GET | `/api/messages/contacts` | Lista conversaciones | +| GET | `/api/messages/history/:contactId` | Historial de mensajes | +| POST | `/api/crm/opportunity` | Crea oportunidad en CRM | +| GET | `/api/crm/inventory` | Consulta inventario de motos | +| GET | `/health` | Health check | diff --git a/chat-nlp-module/backend/Dockerfile b/chat-nlp-module/backend/Dockerfile new file mode 100644 index 0000000..e910791 --- /dev/null +++ b/chat-nlp-module/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only=production + +COPY src/ ./src/ + +RUN mkdir -p logs + +EXPOSE 3001 + +CMD ["node", "src/index.js"] diff --git a/chat-nlp-module/backend/package.json b/chat-nlp-module/backend/package.json new file mode 100644 index 0000000..73a4b93 --- /dev/null +++ b/chat-nlp-module/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "chat-nlp-backend", + "version": "1.0.0", + "description": "Backend para interfaz de chat con NLP y Evolution API", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "axios": "^1.6.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "pg": "^8.11.3", + "socket.io": "^4.7.2", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/chat-nlp-module/backend/src/config.js b/chat-nlp-module/backend/src/config.js new file mode 100644 index 0000000..f6ad013 --- /dev/null +++ b/chat-nlp-module/backend/src/config.js @@ -0,0 +1,30 @@ +require('dotenv').config(); + +module.exports = { + port: process.env.PORT || 3001, + + // Evolution API + evolutionApi: { + baseUrl: process.env.EVOLUTION_API_URL || 'http://localhost:8080', + apiKey: process.env.EVOLUTION_API_KEY || '', + instanceName: process.env.EVOLUTION_INSTANCE_NAME || 'asesores-chat', + }, + + // PostgreSQL / NocoDB + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT, 10) || 5432, + name: process.env.DB_NAME || 'chat_nlp', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', + }, + + // CRM API + crm: { + baseUrl: process.env.CRM_API_URL || 'http://localhost:8081', + apiKey: process.env.CRM_API_KEY || '', + }, + + // Frontend origin for CORS + frontendOrigin: process.env.FRONTEND_ORIGIN || 'http://localhost:3000', +}; diff --git a/chat-nlp-module/backend/src/index.js b/chat-nlp-module/backend/src/index.js new file mode 100644 index 0000000..7271d3b --- /dev/null +++ b/chat-nlp-module/backend/src/index.js @@ -0,0 +1,53 @@ +const express = require('express'); +const http = require('http'); +const cors = require('cors'); +const { Server } = require('socket.io'); +const config = require('./config'); +const logger = require('./utils/logger'); +const webhookRoutes = require('./routes/webhook'); +const messagesRoutes = require('./routes/messages'); +const crmRoutes = require('./routes/crm'); + +const app = express(); +const server = http.createServer(app); + +const io = new Server(server, { + cors: { origin: config.frontendOrigin, methods: ['GET', 'POST'] }, +}); + +// Middleware +app.use(cors({ origin: config.frontendOrigin })); +app.use(express.json()); + +// Expose io instance to routes +app.set('io', io); + +// Routes +app.use('/api/webhook', webhookRoutes); +app.use('/api/messages', messagesRoutes); +app.use('/api/crm', crmRoutes); + +// Health check +app.get('/health', (_req, res) => res.json({ status: 'ok' })); + +// Socket.IO connection handling +io.on('connection', (socket) => { + logger.info(`Asesor conectado: ${socket.id}`); + + socket.on('join_conversation', (contactId) => { + socket.join(`chat:${contactId}`); + logger.info(`Socket ${socket.id} unido a chat:${contactId}`); + }); + + socket.on('leave_conversation', (contactId) => { + socket.leave(`chat:${contactId}`); + }); + + socket.on('disconnect', () => { + logger.info(`Asesor desconectado: ${socket.id}`); + }); +}); + +server.listen(config.port, () => { + logger.info(`Backend escuchando en puerto ${config.port}`); +}); diff --git a/chat-nlp-module/backend/src/routes/crm.js b/chat-nlp-module/backend/src/routes/crm.js new file mode 100644 index 0000000..9c14879 --- /dev/null +++ b/chat-nlp-module/backend/src/routes/crm.js @@ -0,0 +1,62 @@ +const express = require('express'); +const router = express.Router(); +const crmSync = require('../services/crmSync'); +const logger = require('../utils/logger'); + +/** + * POST /api/crm/opportunity + * Crea una oportunidad en el CRM con los datos extraídos del chat + */ +router.post('/opportunity', async (req, res) => { + try { + const { + contactId, + clientName, + clientPhone, + clientEmail, + clientDocument, + profession, + motorcycleModel, + notes, + } = req.body; + + if (!contactId || !clientName) { + return res + .status(400) + .json({ error: 'contactId y clientName son requeridos' }); + } + + const opportunity = await crmSync.createOpportunity({ + contactId, + clientName, + clientPhone, + clientEmail, + clientDocument, + profession, + motorcycleModel, + notes, + }); + + res.json({ success: true, opportunity }); + } catch (error) { + logger.error('Error creando oportunidad', { error: error.message }); + res.status(500).json({ error: 'Error creando oportunidad en CRM' }); + } +}); + +/** + * GET /api/crm/inventory + * Consulta el inventario de motocicletas desde el CRM/base de datos + */ +router.get('/inventory', async (req, res) => { + try { + const { brand, model } = req.query; + const inventory = await crmSync.getInventory({ brand, model }); + res.json(inventory); + } catch (error) { + logger.error('Error consultando inventario', { error: error.message }); + res.status(500).json({ error: 'Error consultando inventario' }); + } +}); + +module.exports = router; diff --git a/chat-nlp-module/backend/src/routes/messages.js b/chat-nlp-module/backend/src/routes/messages.js new file mode 100644 index 0000000..286c9a4 --- /dev/null +++ b/chat-nlp-module/backend/src/routes/messages.js @@ -0,0 +1,96 @@ +const express = require('express'); +const router = express.Router(); +const evolutionApi = require('../services/evolutionApi'); +const logger = require('../utils/logger'); + +/** + * POST /api/messages/send + * Envía un mensaje de texto a través de Evolution API + */ +router.post('/send', async (req, res) => { + try { + const { contactId, text } = req.body; + + if (!contactId || !text) { + return res.status(400).json({ error: 'contactId y text son requeridos' }); + } + + const result = await evolutionApi.sendText(contactId, text); + + const io = req.app.get('io'); + io.to(`chat:${contactId}`).emit('new_message', { + id: result.key?.id || Date.now().toString(), + contactId, + text, + timestamp: new Date().toISOString(), + fromMe: true, + type: 'text', + status: 'sent', + }); + + res.json({ success: true, messageId: result.key?.id }); + } catch (error) { + logger.error('Error enviando mensaje', { error: error.message }); + res.status(500).json({ error: 'Error enviando mensaje' }); + } +}); + +/** + * POST /api/messages/send-media + * Envía un archivo multimedia a través de Evolution API + */ +router.post('/send-media', async (req, res) => { + try { + const { contactId, mediaUrl, caption, mediaType } = req.body; + + if (!contactId || !mediaUrl) { + return res + .status(400) + .json({ error: 'contactId y mediaUrl son requeridos' }); + } + + const result = await evolutionApi.sendMedia( + contactId, + mediaUrl, + caption || '', + mediaType || 'image' + ); + + res.json({ success: true, messageId: result.key?.id }); + } catch (error) { + logger.error('Error enviando media', { error: error.message }); + res.status(500).json({ error: 'Error enviando media' }); + } +}); + +/** + * GET /api/messages/contacts + * Lista los contactos/conversaciones activas desde Evolution API + */ +router.get('/contacts', async (_req, res) => { + try { + const contacts = await evolutionApi.getContacts(); + res.json(contacts); + } catch (error) { + logger.error('Error obteniendo contactos', { error: error.message }); + res.status(500).json({ error: 'Error obteniendo contactos' }); + } +}); + +/** + * GET /api/messages/history/:contactId + * Obtiene historial de mensajes con un contacto + */ +router.get('/history/:contactId', async (req, res) => { + try { + const { contactId } = req.params; + const limit = parseInt(req.query.limit, 10) || 50; + const messages = await evolutionApi.getMessages(contactId, limit); + res.json(messages); + } catch (error) { + logger.error('Error obteniendo historial', { error: error.message }); + res.status(500).json({ error: 'Error obteniendo historial' }); + } +}); + +module.exports = router; diff --git a/chat-nlp-module/backend/src/routes/webhook.js b/chat-nlp-module/backend/src/routes/webhook.js new file mode 100644 index 0000000..d7b5138 --- /dev/null +++ b/chat-nlp-module/backend/src/routes/webhook.js @@ -0,0 +1,74 @@ +const express = require('express'); +const router = express.Router(); +const logger = require('../utils/logger'); +const nlpExtractor = require('../services/nlpExtractor'); + +/** + * POST /api/webhook/evolution + * Recibe eventos de Evolution API (mensajes entrantes, status updates, etc.) + */ +router.post('/evolution', async (req, res) => { + try { + const event = req.body; + const io = req.app.get('io'); + + logger.info('Webhook recibido de Evolution API', { + event: event.event, + instance: event.instance, + }); + + if (event.event === 'messages.upsert') { + const message = event.data; + const contactId = message.key?.remoteJid; + const isFromMe = message.key?.fromMe || false; + const text = + message.message?.conversation || + message.message?.extendedTextMessage?.text || + ''; + + const chatMessage = { + id: message.key?.id, + contactId, + text, + timestamp: message.messageTimestamp + ? new Date(message.messageTimestamp * 1000).toISOString() + : new Date().toISOString(), + fromMe: isFromMe, + type: 'text', + status: 'received', + }; + + // Run NLP extraction on incoming client messages + if (!isFromMe && text) { + const extracted = nlpExtractor.extract(text); + if (Object.keys(extracted).length > 0) { + chatMessage.extractedData = extracted; + logger.info('Datos extraídos por NLP', { contactId, extracted }); + } + } + + // Emit to all advisors watching this conversation + io.to(`chat:${contactId}`).emit('new_message', chatMessage); + + // Also emit to the global conversation list for unread indicators + io.emit('conversation_update', { + contactId, + lastMessage: text, + timestamp: chatMessage.timestamp, + fromMe: isFromMe, + }); + } + + if (event.event === 'messages.update') { + const update = event.data; + io.emit('message_status_update', update); + } + + res.status(200).json({ received: true }); + } catch (error) { + logger.error('Error procesando webhook', { error: error.message }); + res.status(500).json({ error: 'Error procesando evento' }); + } +}); + +module.exports = router; diff --git a/chat-nlp-module/backend/src/services/crmSync.js b/chat-nlp-module/backend/src/services/crmSync.js new file mode 100644 index 0000000..402ec35 --- /dev/null +++ b/chat-nlp-module/backend/src/services/crmSync.js @@ -0,0 +1,72 @@ +const axios = require('axios'); +const config = require('../config'); +const logger = require('../utils/logger'); + +const api = axios.create({ + baseURL: config.crm.baseUrl, + headers: { + Authorization: `Bearer ${config.crm.apiKey}`, + 'Content-Type': 'application/json', + }, +}); + +/** + * Crea una oportunidad de venta en el CRM vinculando los datos + * extraídos del chat por el módulo NLP. + */ +async function createOpportunity(data) { + const payload = { + title: `Oportunidad - ${data.clientName}`, + contact: { + name: data.clientName, + phone: data.clientPhone || data.contactId, + email: data.clientEmail || '', + document: data.clientDocument || '', + }, + details: { + profession: data.profession || '', + motorcycleModel: data.motorcycleModel || '', + source: 'whatsapp-chat', + notes: data.notes || '', + }, + status: 'new', + createdAt: new Date().toISOString(), + }; + + try { + const response = await api.post('/api/opportunities', payload); + logger.info('Oportunidad creada en CRM', { + opportunityId: response.data.id, + client: data.clientName, + }); + return response.data; + } catch (error) { + logger.error('Error al crear oportunidad en CRM', { + error: error.message, + status: error.response?.status, + }); + throw error; + } +} + +/** + * Consulta el inventario de motocicletas disponible. + * Puede filtrar por marca y/o modelo. + */ +async function getInventory({ brand, model } = {}) { + const params = {}; + if (brand) params.brand = brand; + if (model) params.model = model; + + try { + const response = await api.get('/api/inventory', { params }); + return response.data; + } catch (error) { + logger.error('Error al consultar inventario', { + error: error.message, + }); + throw error; + } +} + +module.exports = { createOpportunity, getInventory }; diff --git a/chat-nlp-module/backend/src/services/evolutionApi.js b/chat-nlp-module/backend/src/services/evolutionApi.js new file mode 100644 index 0000000..4f95b16 --- /dev/null +++ b/chat-nlp-module/backend/src/services/evolutionApi.js @@ -0,0 +1,60 @@ +const axios = require('axios'); +const config = require('../config'); +const logger = require('../utils/logger'); + +const api = axios.create({ + baseURL: `${config.evolutionApi.baseUrl}`, + headers: { + apikey: config.evolutionApi.apiKey, + 'Content-Type': 'application/json', + }, +}); + +const instance = config.evolutionApi.instanceName; + +/** + * Envía un mensaje de texto a un contacto vía Evolution API + */ +async function sendText(contactId, text) { + const response = await api.post(`/message/sendText/${instance}`, { + number: contactId, + text, + }); + logger.info('Mensaje enviado', { contactId, textLength: text.length }); + return response.data; +} + +/** + * Envía un archivo multimedia a un contacto + */ +async function sendMedia(contactId, mediaUrl, caption, mediaType) { + const response = await api.post(`/message/sendMedia/${instance}`, { + number: contactId, + mediatype: mediaType, + media: mediaUrl, + caption, + }); + logger.info('Media enviado', { contactId, mediaType }); + return response.data; +} + +/** + * Obtiene la lista de contactos/chats de la instancia + */ +async function getContacts() { + const response = await api.get(`/chat/findChats/${instance}`); + return response.data; +} + +/** + * Obtiene el historial de mensajes con un contacto específico + */ +async function getMessages(contactId, limit = 50) { + const response = await api.post(`/chat/findMessages/${instance}`, { + where: { key: { remoteJid: contactId } }, + limit, + }); + return response.data; +} + +module.exports = { sendText, sendMedia, getContacts, getMessages }; diff --git a/chat-nlp-module/backend/src/services/nlpExtractor.js b/chat-nlp-module/backend/src/services/nlpExtractor.js new file mode 100644 index 0000000..3c65092 --- /dev/null +++ b/chat-nlp-module/backend/src/services/nlpExtractor.js @@ -0,0 +1,200 @@ +const logger = require('../utils/logger'); + +/** + * Módulo de Extracción de Datos NLP + * + * Detecta y extrae automáticamente datos del cliente a partir de + * mensajes de texto en la conversación: + * - Nombre del cliente + * - Número de teléfono / contacto + * - Documento de identidad (cédula colombiana) + * - Correo electrónico + * - Profesión (dato Auteco) + * - Modelo de motocicleta de interés (catálogo Auteco) + */ + +// Catálogo de modelos Auteco para matching +const AUTECO_MODELS = [ + 'Bajaj Pulsar NS 125', + 'Bajaj Pulsar NS 160', + 'Bajaj Pulsar NS 200', + 'Bajaj Pulsar 200 RS', + 'Bajaj Pulsar N 250', + 'Bajaj Dominar 250', + 'Bajaj Dominar 400', + 'Bajaj Boxer CT 100', + 'Bajaj Platina 100', + 'Bajaj Discover 125', + 'KTM Duke 200', + 'KTM Duke 250', + 'KTM Duke 390', + 'KTM RC 200', + 'KTM Adventure 250', + 'KTM Adventure 390', + 'Husqvarna Svartpilen 200', + 'Husqvarna Vitpilen 200', + 'Kawasaki Versys 650', + 'Kawasaki Z400', + 'Kawasaki Ninja 400', +]; + +// Build regex patterns for motorcycle models (case-insensitive partial matching) +const modelPatterns = AUTECO_MODELS.map((model) => { + // Allow flexible matching: "pulsar ns200", "ns 200", "duke 390", etc. + const parts = model.split(' '); + const brand = parts[0]; + const rest = parts.slice(1).join('\\s*'); + return { + fullName: model, + regex: new RegExp(`${rest}`, 'i'), + brandRegex: new RegExp(`${brand}`, 'i'), + }; +}); + +// Common professions in Colombia +const PROFESSIONS = [ + 'ingeniero', + 'abogado', + 'médico', + 'doctor', + 'doctora', + 'contador', + 'contadora', + 'arquitecto', + 'profesor', + 'profesora', + 'docente', + 'enfermero', + 'enfermera', + 'técnico', + 'tecnólogo', + 'administrador', + 'comerciante', + 'vendedor', + 'vendedora', + 'conductor', + 'mecánico', + 'electricista', + 'plomero', + 'carpintero', + 'agricultor', + 'ganadero', + 'estudiante', + 'pensionado', + 'independiente', + 'militar', + 'policía', + 'bombero', + 'piloto', + 'periodista', + 'diseñador', + 'programador', + 'desarrollador', + 'economista', + 'psicólogo', + 'odontólogo', + 'veterinario', + 'farmaceuta', + 'chef', + 'cocinero', + 'mesero', + 'secretaria', + 'asesor', + 'asesora', +]; + +const PATTERNS = { + /** + * Email: standard email regex + */ + email: /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/, + + /** + * Cédula colombiana: 6-10 dígitos, opcionalmente con puntos como separadores + * e.g., "1.234.567.890", "1234567890", "CC 1234567890" + */ + document: + /(?:(?:cc|c\.?c\.?|cédula|cedula|documento|doc)\s*:?\s*#?\s*)(\d{1,3}(?:\.\d{3}){1,3}|\d{6,10})/i, + + /** + * Teléfono colombiano: +57, 57, 3xx xxx xxxx, etc. + */ + phone: + /(?:(?:tel|teléfono|telefono|celular|cel|whatsapp|wsp|número|numero)\s*:?\s*)?(?:\+?57\s?)?([3][0-9]{2}\s?\d{3}\s?\d{4})/i, + + /** + * Nombre: triggered by phrases like "mi nombre es", "me llamo", "soy [Name]" + */ + name: /(?:(?:mi nombre es|me llamo|soy|nombre\s*:)\s+)([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){0,3})/, + + /** + * Profession: "soy ingeniero", "trabajo como ...", "profesión: ..." + */ + profession: + /(?:(?:soy|trabajo como|trabajo de|profesión|profesion|ocupación|ocupacion)\s*:?\s+)([\wáéíóúñ]+)/i, +}; + +/** + * Extract structured data from a raw text message. + * Returns an object with only the fields that were detected. + */ +function extract(text) { + const result = {}; + + // Email + const emailMatch = text.match(PATTERNS.email); + if (emailMatch) { + result.email = emailMatch[0].toLowerCase(); + } + + // Document (cédula) + const docMatch = text.match(PATTERNS.document); + if (docMatch) { + result.document = docMatch[1].replace(/\./g, ''); + } + + // Phone + const phoneMatch = text.match(PATTERNS.phone); + if (phoneMatch) { + result.phone = phoneMatch[1].replace(/\s/g, ''); + } + + // Name + const nameMatch = text.match(PATTERNS.name); + if (nameMatch) { + result.name = nameMatch[1].trim(); + } + + // Profession + const profMatch = text.match(PATTERNS.profession); + if (profMatch) { + const candidate = profMatch[1].toLowerCase(); + if (PROFESSIONS.includes(candidate)) { + result.profession = candidate; + } + } + + // Motorcycle model (Auteco catalog) + for (const mp of modelPatterns) { + if (mp.regex.test(text)) { + result.motorcycleModel = mp.fullName; + break; + } + } + + if (Object.keys(result).length > 0) { + logger.debug('NLP extraction result', { result, inputLength: text.length }); + } + + return result; +} + +/** + * Accumulates extracted data from multiple messages into a single profile. + * New values overwrite previous ones. + */ +function mergeExtracted(existing, newData) { + return { ...existing, ...newData }; +} + +module.exports = { extract, mergeExtracted, AUTECO_MODELS }; diff --git a/chat-nlp-module/backend/src/utils/logger.js b/chat-nlp-module/backend/src/utils/logger.js new file mode 100644 index 0000000..df02101 --- /dev/null +++ b/chat-nlp-module/backend/src/utils/logger.js @@ -0,0 +1,21 @@ +const winston = require('winston'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + }), + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), + new winston.transports.File({ filename: 'logs/combined.log' }), + ], +}); + +module.exports = logger; diff --git a/chat-nlp-module/docker-compose.yml b/chat-nlp-module/docker-compose.yml new file mode 100644 index 0000000..49e1bd3 --- /dev/null +++ b/chat-nlp-module/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: chat-nlp-backend + restart: unless-stopped + ports: + - '3001:3001' + env_file: + - .env + depends_on: + - postgres + networks: + - chat-network + + frontend: + build: ./frontend + container_name: chat-nlp-frontend + restart: unless-stopped + ports: + - '3000:80' + depends_on: + - backend + networks: + - chat-network + + postgres: + image: postgres:15-alpine + container_name: chat-nlp-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-chat_nlp} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - '5432:5432' + networks: + - chat-network + +volumes: + pgdata: + +networks: + chat-network: + driver: bridge diff --git a/chat-nlp-module/frontend/Dockerfile b/chat-nlp-module/frontend/Dockerfile new file mode 100644 index 0000000..f95be3e --- /dev/null +++ b/chat-nlp-module/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY public/ ./public/ +COPY src/ ./src/ + +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/chat-nlp-module/frontend/nginx.conf b/chat-nlp-module/frontend/nginx.conf new file mode 100644 index 0000000..7c9490e --- /dev/null +++ b/chat-nlp-module/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location /socket.io/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/chat-nlp-module/frontend/package.json b/chat-nlp-module/frontend/package.json new file mode 100644 index 0000000..f1b232d --- /dev/null +++ b/chat-nlp-module/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "chat-nlp-frontend", + "version": "1.0.0", + "description": "Interfaz de chat para asesores con extracción NLP", + "private": true, + "dependencies": { + "axios": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "socket.io-client": "^4.7.2" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version"] + } +} diff --git a/chat-nlp-module/frontend/public/index.html b/chat-nlp-module/frontend/public/index.html new file mode 100644 index 0000000..b1964d2 --- /dev/null +++ b/chat-nlp-module/frontend/public/index.html @@ -0,0 +1,11 @@ + + + + + + Chat Asesores - Auteco + + +
+ + diff --git a/chat-nlp-module/frontend/src/App.js b/chat-nlp-module/frontend/src/App.js new file mode 100644 index 0000000..bc9933f --- /dev/null +++ b/chat-nlp-module/frontend/src/App.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { ChatProvider } from './context/ChatContext'; +import ConversationList from './components/ConversationList'; +import ChatPanel from './components/ChatPanel'; +import ExtractedDataPanel from './components/ExtractedDataPanel'; +import InventoryPanel from './components/InventoryPanel'; + +export default function App() { + return ( + +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/chat-nlp-module/frontend/src/components/ChatPanel.jsx b/chat-nlp-module/frontend/src/components/ChatPanel.jsx new file mode 100644 index 0000000..a6f9864 --- /dev/null +++ b/chat-nlp-module/frontend/src/components/ChatPanel.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useChat } from '../context/ChatContext'; +import MessageList from './MessageList'; +import MessageInput from './MessageInput'; +import ContactInfo from './ContactInfo'; + +export default function ChatPanel() { + const { activeContactId, loadingMessages } = useChat(); + + if (!activeContactId) { + return ( +
+
+

Selecciona una conversación

+

Elige un contacto del panel izquierdo para ver los mensajes

+
+
+ ); + } + + return ( +
+ + {loadingMessages ? ( +
Cargando mensajes...
+ ) : ( + + )} + +
+ ); +} diff --git a/chat-nlp-module/frontend/src/components/ContactInfo.jsx b/chat-nlp-module/frontend/src/components/ContactInfo.jsx new file mode 100644 index 0000000..6c07871 --- /dev/null +++ b/chat-nlp-module/frontend/src/components/ContactInfo.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useChat } from '../context/ChatContext'; + +export default function ContactInfo() { + const { activeContactId, conversations } = useChat(); + const conv = conversations.find( + (c) => (c.remoteJid || c.id) === activeContactId + ); + + const displayName = + conv?.name || conv?.pushName || activeContactId?.replace(/@.*/, '') || ''; + + return ( +
+
+ {displayName.charAt(0).toUpperCase()} +
+
+ {displayName} + {activeContactId} +
+
+ ); +} diff --git a/chat-nlp-module/frontend/src/components/ConversationList.jsx b/chat-nlp-module/frontend/src/components/ConversationList.jsx new file mode 100644 index 0000000..935ea7d --- /dev/null +++ b/chat-nlp-module/frontend/src/components/ConversationList.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useChat } from '../context/ChatContext'; + +export default function ConversationList() { + const { conversations, activeContactId, setActiveContact } = useChat(); + + if (!conversations.length) { + return
No hay conversaciones activas
; + } + + return ( + + ); +} diff --git a/chat-nlp-module/frontend/src/components/ExtractedDataPanel.jsx b/chat-nlp-module/frontend/src/components/ExtractedDataPanel.jsx new file mode 100644 index 0000000..d844f72 --- /dev/null +++ b/chat-nlp-module/frontend/src/components/ExtractedDataPanel.jsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { useChat } from '../context/ChatContext'; +import { createOpportunity } from '../services/api'; + +const FIELD_LABELS = { + name: 'Nombre', + phone: 'Teléfono', + email: 'Email', + document: 'Cédula', + profession: 'Profesión', + motorcycleModel: 'Modelo moto', +}; + +export default function ExtractedDataPanel() { + const { activeContactId, extractedData } = useChat(); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + const data = activeContactId ? extractedData[activeContactId] : null; + + const handleCreateOpportunity = async () => { + if (!data?.name || !activeContactId) return; + + setSyncing(true); + setSyncResult(null); + try { + await createOpportunity({ + contactId: activeContactId, + clientName: data.name, + clientPhone: data.phone, + clientEmail: data.email, + clientDocument: data.document, + profession: data.profession, + motorcycleModel: data.motorcycleModel, + }); + setSyncResult('success'); + } catch { + setSyncResult('error'); + } finally { + setSyncing(false); + } + }; + + if (!activeContactId) { + return ( +
+

Datos del cliente

+

Selecciona una conversación

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

Datos extraídos (NLP)

+ {data && Object.keys(data).length > 0 ? ( + <> +
+ {Object.entries(FIELD_LABELS).map(([key, label]) => + data[key] ? ( +
+
{label}
+
{data[key]}
+
+ ) : null + )} +
+ + {syncResult === 'success' && ( +

Oportunidad creada exitosamente

+ )} + {syncResult === 'error' && ( +

Error al crear oportunidad

+ )} + + ) : ( +

+ Los datos se extraerán automáticamente de la conversación +

+ )} +
+ ); +} diff --git a/chat-nlp-module/frontend/src/components/InventoryPanel.jsx b/chat-nlp-module/frontend/src/components/InventoryPanel.jsx new file mode 100644 index 0000000..88ccd82 --- /dev/null +++ b/chat-nlp-module/frontend/src/components/InventoryPanel.jsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect } from 'react'; +import { getInventory } from '../services/api'; + +export default function InventoryPanel() { + const [inventory, setInventory] = useState([]); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!expanded) return; + + setLoading(true); + getInventory() + .then((data) => setInventory(Array.isArray(data) ? data : [])) + .catch(() => setInventory([])) + .finally(() => setLoading(false)); + }, [expanded]); + + const filtered = inventory.filter((item) => { + const q = search.toLowerCase(); + return ( + (item.model || '').toLowerCase().includes(q) || + (item.brand || '').toLowerCase().includes(q) + ); + }); + + return ( +
+ + + {expanded && ( + <> + setSearch(e.target.value)} + /> + + {loading ? ( +

Cargando inventario...

+ ) : filtered.length > 0 ? ( +
    + {filtered.map((item, idx) => ( +
  • + + {item.brand} {item.model} + + + Stock: {item.stock ?? 'N/A'} + + + {item.price + ? `$${Number(item.price).toLocaleString('es-CO')}` + : ''} + +
  • + ))} +
+ ) : ( +

+ {search ? 'Sin resultados' : 'Inventario no disponible'} +

+ )} + + )} +
+ ); +} diff --git a/chat-nlp-module/frontend/src/components/MessageInput.jsx b/chat-nlp-module/frontend/src/components/MessageInput.jsx new file mode 100644 index 0000000..b82f059 --- /dev/null +++ b/chat-nlp-module/frontend/src/components/MessageInput.jsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { useChat } from '../context/ChatContext'; +import { sendMessage } from '../services/api'; + +export default function MessageInput() { + const { activeContactId } = useChat(); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + const trimmed = text.trim(); + if (!trimmed || !activeContactId || sending) return; + + setSending(true); + try { + await sendMessage(activeContactId, trimmed); + setText(''); + } catch { + // Error handled by backend logger; could add toast notification here + } finally { + setSending(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+