mirror of
https://github.com/marcogll/telegram_new_socias.git
synced 2026-01-13 13:15:16 +00:00
feat: Implement AI-powered permit reason classification and add comprehensive project documentation.
This commit is contained in:
13
.env.example
13
.env.example
@@ -1,10 +1,16 @@
|
||||
# Configuración de Telegram
|
||||
TELEGRAM_TOKEN=TU_TOKEN_NUEVO_AQUI
|
||||
TELEGRAM_ADMIN_CHAT_ID=TELEGRAM_ADMIN_CHAT_ID
|
||||
OPENAI_API_KEY=SK......
|
||||
GOOGLE_API_KEY=AIzaSyBqH5...
|
||||
|
||||
|
||||
# Webhooks de n8n (puedes agregar más aquí en el futuro)
|
||||
WEBHOOK_CONTRATO=
|
||||
WEBHOOK_PRINT=
|
||||
WEBHOOK_VACACIONES=
|
||||
WEBHOOK_CONTRATO=url
|
||||
WEBHOOK_PRINT=url
|
||||
WEBHOOK_VACACIONES=url
|
||||
WEBHOOK_PERMISOS=url
|
||||
WEBHOOK_DATA_EMPLEADO=url
|
||||
|
||||
# --- DATABASE ---
|
||||
# Usado por el servicio de la base de datos en docker-compose.yml
|
||||
@@ -23,4 +29,5 @@ IMAP_SERVER=imap.hostinger.com
|
||||
IMAP_PORT=993
|
||||
IMAP_USER=your_email@example.com
|
||||
IMAP_PASSWORD=your_password
|
||||
PRINTER_EMAIL=your_email@example.com
|
||||
|
||||
|
||||
BIN
Screenshot_20251214-112252.png
Normal file
BIN
Screenshot_20251214-112252.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
277
Vanessa.md
Normal file
277
Vanessa.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# 👩💼 Vanessa Bot Brain
|
||||
|
||||
### Vanity / Soul — HR Automation
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Vanessa** es la Asistente Virtual de Recursos Humanos para **Vanity / Soul**. No es solo un bot de comandos: es un **cerebro central modular** diseñado para gestionar el ciclo de vida de las socias —contratación, solicitudes internas y servicios— con una personalidad cálida, eficiente y humana.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Filosofía del Diseño
|
||||
|
||||
Vanessa fue construida bajo tres principios:
|
||||
|
||||
1. **Conversaciones humanas, datos estrictos**
|
||||
La UX es natural; la salida siempre es un **payload JSON inmutable**.
|
||||
|
||||
2. **Estado efímero, persistencia externa**
|
||||
El bot no guarda información sensible. Todo se envía a **n8n + Base de Datos**.
|
||||
|
||||
3. **Modularidad real**
|
||||
Cada habilidad vive en su propio archivo y puede evolucionar sin romper el sistema.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arquitectura del Sistema
|
||||
|
||||
Arquitectura **modular y desacoplada**:
|
||||
|
||||
* **Cerebro (`main.py`)**
|
||||
Orquesta Telegram, gestiona sesiones y enruta comandos.
|
||||
|
||||
* **Habilidades (`/modules`)**
|
||||
Cada módulo implementa un flujo conversacional completo.
|
||||
|
||||
* **Inteligencia Artificial**
|
||||
OpenAI o Gemini para clasificación semántica y entendimiento de texto libre.
|
||||
|
||||
* **Persistencia (n8n + DB)**
|
||||
Recepción de eventos mediante Webhooks con UUID y timestamp.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
/vanity_bot_brain
|
||||
│
|
||||
├── .env # Credenciales y Webhooks
|
||||
├── main.py # Cerebro / Orquestador
|
||||
├── requirements.txt # Dependencias
|
||||
├── README.md # Documentación
|
||||
│
|
||||
└── modules/ # HABILIDADES
|
||||
├── __init__.py
|
||||
├── onboarding.py # /welcome — Contrato (35 pasos)
|
||||
├── rh_requests.py # /vacaciones y /permiso (IA)
|
||||
└── printer.py # /print — Envío de archivos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💬 Módulos y Flujos Conversacionales
|
||||
|
||||
### 1️⃣ Onboarding — `/welcome`
|
||||
|
||||
**Objetivo**
|
||||
Recopilar la información completa para el contrato de nuevas socias.
|
||||
|
||||
**Características clave**
|
||||
|
||||
* Flujo guiado de **35 pasos**
|
||||
* Normalización de RFC y CURP
|
||||
* Validación de fechas
|
||||
* Teclados dinámicos
|
||||
* Referencias personales en bucle
|
||||
|
||||
**Ejemplo de conversación**
|
||||
|
||||
```
|
||||
User: /welcome
|
||||
Vanessa: ¡Hola Ana! 👋 Soy Vanessa de RH. Vamos a dejar listo tu registro.
|
||||
Vanessa: ¿Cómo te gusta que te llamemos?
|
||||
User: Anita
|
||||
Vanessa: Perfecto ✨ ¿Cuál es tu nombre completo como aparece en tu INE?
|
||||
...
|
||||
Vanessa: ¡Listo! ✅ Tu información ya está en el sistema. Bienvenida a Vanity.
|
||||
```
|
||||
|
||||
**Payload enviado a n8n**
|
||||
|
||||
```json
|
||||
{
|
||||
"candidato": {
|
||||
"nombre_oficial": "ANA MARIA PEREZ",
|
||||
"rfc": "PEQA901010...",
|
||||
"curp": "PEQA901010...",
|
||||
"fecha_nacimiento": "1990-10-10"
|
||||
},
|
||||
"laboral": {
|
||||
"rol_id": "manager",
|
||||
"sucursal_id": "plaza_cima",
|
||||
"fecha_inicio": "2026-01-15"
|
||||
},
|
||||
"referencias": [
|
||||
{ "nombre": "Juan", "telefono": "555...", "relacion": "Trabajo" },
|
||||
{ "nombre": "Luisa", "telefono": "844...", "relacion": "Familiar" }
|
||||
],
|
||||
"metadata": {
|
||||
"timestamp": "2025-12-14T10:00:00-06:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Vacaciones — `/vacaciones`
|
||||
|
||||
**Objetivo**
|
||||
Gestionar descansos aplicando reglas de negocio automáticamente.
|
||||
|
||||
**Semáforo de decisión**
|
||||
|
||||
* 🔴 Menos de 5 días → Rechazo automático
|
||||
* 🟡 6 a 11 días → Revisión manual
|
||||
* 🟢 12+ días → Pre-aprobado
|
||||
|
||||
**Ejemplo**
|
||||
|
||||
```
|
||||
Vanessa: Días solicitados: 6
|
||||
Vanessa: Anticipación: 35 días
|
||||
🟢 Excelente planeación. Solicitud pre-aprobada.
|
||||
```
|
||||
|
||||
**Payload generado**
|
||||
|
||||
```json
|
||||
{
|
||||
"record_id": "uuid-v4-unico",
|
||||
"tipo_solicitud": "VACACIONES",
|
||||
"fechas": {
|
||||
"inicio": "2026-01-20",
|
||||
"fin": "2026-01-25"
|
||||
},
|
||||
"metricas": {
|
||||
"dias_totales": 6,
|
||||
"dias_anticipacion": 35
|
||||
},
|
||||
"status_inicial": "PRE_APROBADO",
|
||||
"created_at": "2025-12-14T10:05:00-06:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Permisos con IA — `/permiso`
|
||||
|
||||
**Objetivo**
|
||||
Registrar incidencias, salidas o permisos cortos.
|
||||
|
||||
**IA aplicada**
|
||||
El bot analiza el texto libre y clasifica el motivo:
|
||||
|
||||
* EMERGENCIA
|
||||
* MÉDICO
|
||||
* TRÁMITE
|
||||
* PERSONAL
|
||||
|
||||
**Ejemplo**
|
||||
|
||||
```
|
||||
Usuario: Mi hijo se cayó en la escuela
|
||||
Vanessa: Categoría detectada → EMERGENCIA 🚨
|
||||
```
|
||||
|
||||
**Payload**
|
||||
|
||||
```json
|
||||
{
|
||||
"record_id": "uuid-v4-unico",
|
||||
"tipo_solicitud": "PERMISO",
|
||||
"motivo_usuario": "Mi hijo se cayó en la escuela...",
|
||||
"categoria_detectada": "EMERGENCIA",
|
||||
"fechas": {
|
||||
"inicio": "2025-12-15",
|
||||
"fin": "2025-12-15"
|
||||
},
|
||||
"created_at": "2025-12-14T10:10:00-06:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Impresión — `/print`
|
||||
|
||||
**Objetivo**
|
||||
Enviar documentos directamente a la cola de impresión de la oficina.
|
||||
|
||||
**Soporta**
|
||||
|
||||
* PDF
|
||||
* Word
|
||||
* Imágenes
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Instalación y Ejecución con Docker
|
||||
|
||||
### Requisitos
|
||||
|
||||
* Docker
|
||||
* Docker Compose
|
||||
|
||||
### 1. Configuración del Entorno
|
||||
|
||||
Antes de iniciar, es necesario configurar las variables de entorno.
|
||||
|
||||
1. **Crear el archivo `.env`**: Copia el archivo de ejemplo `.env.example` y renómbralo a `.env`.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. **Editar las variables**: Abre el archivo `.env` y rellena todas las variables requeridas:
|
||||
* `TELEGRAM_TOKEN`: El token de tu bot de Telegram.
|
||||
* `GOOGLE_API_KEY`: Tu clave de API de Google para la IA de Gemini.
|
||||
* `WEBHOOK_*`: Las URLs de los webhooks de tu sistema de automatización (ej. n8n).
|
||||
* `MYSQL_*`: Las credenciales para la base de datos (puedes dejar las que vienen por defecto si solo es para desarrollo local).
|
||||
* `SMTP_*`: Las credenciales de tu servidor de correo para la función de impresión.
|
||||
|
||||
### 2. Construcción y Ejecución
|
||||
|
||||
Una vez configurado el entorno, el proyecto se gestiona fácilmente con Docker Compose.
|
||||
|
||||
1. **Construir las imágenes**: Este comando crea las imágenes de Docker para el bot y la base de datos.
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
2. **Iniciar los servicios**: Este comando inicia el bot y la base de datos en segundo plano.
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
El bot ahora estará en funcionamiento. Para detener los servicios, puedes usar `docker-compose down`.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Esquema de Base de Datos Sugerido
|
||||
|
||||
Tabla: **rh_solicitudes**
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
| ----------------- | --------- | ---------------------- |
|
||||
| record_id | UUID | Identificador único |
|
||||
| user_id | BIGINT | Telegram ID |
|
||||
| nombre | VARCHAR | Nombre del colaborador |
|
||||
| tipo | VARCHAR | VACACIONES / PERMISO |
|
||||
| fechas | JSON | Rango de fechas |
|
||||
| motivo | TEXT | Texto original |
|
||||
| categoria | VARCHAR | Clasificación IA |
|
||||
| dias_anticipacion | INT | Métrica RH |
|
||||
| status_bot | VARCHAR | Resultado automático |
|
||||
| created_at | TIMESTAMP | Zona MTY |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Extensibilidad
|
||||
|
||||
Para agregar un nuevo comando:
|
||||
|
||||
1. Crear un archivo en `/modules`
|
||||
2. Implementar su flujo
|
||||
3. Registrar el comando en `main.py`
|
||||
|
||||
Vanessa ya sabe pensar. Solo enséñale una nueva habilidad. 🧠
|
||||
@@ -7,8 +7,6 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DATABASE_URL=mysql+mysqlconnector://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE}
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
|
||||
41
modules/ai.py
Normal file
41
modules/ai.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import google.generativeai as genai
|
||||
|
||||
def classify_reason(text: str) -> str:
|
||||
"""
|
||||
Clasifica el motivo de un permiso utilizando la API de Gemini.
|
||||
|
||||
Args:
|
||||
text: El motivo del permiso proporcionado por el usuario.
|
||||
|
||||
Returns:
|
||||
La categoría clasificada (EMERGENCIA, MÉDICO, TRÁMITE, PERSONAL) o "OTRO" si no se puede clasificar.
|
||||
"""
|
||||
try:
|
||||
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
|
||||
|
||||
model = genai.GenerativeModel('gemini-pro')
|
||||
|
||||
prompt = f"""
|
||||
Clasifica el siguiente motivo de solicitud de permiso en una de estas cuatro categorías: EMERGENCIA, MÉDICO, TRÁMITE, PERSONAL.
|
||||
Responde únicamente con la palabra de la categoría en mayúsculas.
|
||||
|
||||
Motivo: "{text}"
|
||||
Categoría:
|
||||
"""
|
||||
|
||||
response = model.generate_content(prompt)
|
||||
|
||||
# Limpiar la respuesta para obtener solo la categoría
|
||||
category = response.text.strip().upper()
|
||||
|
||||
# Validar que la categoría sea una de las esperadas
|
||||
valid_categories = ["EMERGENCIA", "MÉDICO", "TRÁMITE", "PERSONAL"]
|
||||
if category in valid_categories:
|
||||
return category
|
||||
else:
|
||||
return "PERSONAL" # Si la IA devuelve algo inesperado, se asigna a PERSONAL
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error al clasificar con IA: {e}")
|
||||
return "PERSONAL" # En caso de error, se asigna a PERSONAL por defecto
|
||||
@@ -8,12 +8,21 @@ import logging
|
||||
# Configuración de logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Obtener la URL de la base de datos desde las variables de entorno
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+mysqlconnector://user:password@db:3306/vanessa_logs")
|
||||
# Construir la URL de la base de datos desde las variables de entorno individuales
|
||||
try:
|
||||
user = os.getenv("MYSQL_USER")
|
||||
password = os.getenv("MYSQL_PASSWORD")
|
||||
host = "db" # El nombre del servicio de la base de datos en docker-compose
|
||||
database = os.getenv("MYSQL_DATABASE")
|
||||
DATABASE_URL = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{database}"
|
||||
|
||||
# Crear el motor de la base de datos
|
||||
engine = create_engine(DATABASE_URL)
|
||||
metadata = MetaData()
|
||||
# Crear el motor de la base de datos
|
||||
engine = create_engine(DATABASE_URL)
|
||||
metadata = MetaData()
|
||||
|
||||
except AttributeError:
|
||||
logging.error("Error: Faltan una o más variables de entorno para la base de datos (MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE).")
|
||||
exit(1)
|
||||
|
||||
# Base para los modelos declarativos
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
|
||||
from modules.database import log_request
|
||||
from modules.ai import classify_reason
|
||||
|
||||
TIPO_SOLICITITUD, FECHAS, MOTIVO = range(3)
|
||||
|
||||
def _calculate_vacation_metrics(date_string: str) -> dict:
|
||||
"""
|
||||
Calcula métricas de vacaciones a partir de un texto.
|
||||
Asume un formato como "10 al 15 de Octubre".
|
||||
"""
|
||||
today = date.today()
|
||||
current_year = today.year
|
||||
|
||||
# Mapeo de meses en español a número
|
||||
meses = {
|
||||
'enero': 1, 'febrero': 2, 'marzo': 3, 'abril': 4, 'mayo': 5, 'junio': 6,
|
||||
'julio': 7, 'agosto': 8, 'septiembre': 9, 'octubre': 10, 'noviembre': 11, 'diciembre': 12
|
||||
}
|
||||
|
||||
# Regex para "10 al 15 de Octubre"
|
||||
match = re.search(r'(\d{1,2})\s*al\s*(\d{1,2})\s*de\s*(\w+)', date_string, re.IGNORECASE)
|
||||
|
||||
if not match:
|
||||
return {"dias_totales": 0, "dias_anticipacion": 0}
|
||||
|
||||
start_day, end_day, month_str = match.groups()
|
||||
start_day, end_day = int(start_day), int(end_day)
|
||||
month = meses.get(month_str.lower())
|
||||
|
||||
if not month:
|
||||
return {"dias_totales": 0, "dias_anticipacion": 0}
|
||||
|
||||
try:
|
||||
start_date = date(current_year, month, start_day)
|
||||
# Si la fecha ya pasó este año, asumir que es del próximo año
|
||||
if start_date < today:
|
||||
start_date = date(current_year + 1, month, start_day)
|
||||
|
||||
end_date = date(start_date.year, month, end_day)
|
||||
|
||||
dias_totales = (end_date - start_date).days + 1
|
||||
dias_anticipacion = (start_date - today).days
|
||||
|
||||
return {"dias_totales": dias_totales, "dias_anticipacion": dias_anticipacion, "fechas_calculadas": {"inicio": start_date.isoformat(), "fin": end_date.isoformat()}}
|
||||
except ValueError:
|
||||
return {"dias_totales": 0, "dias_anticipacion": 0}
|
||||
|
||||
TIPO_SOLICITUD, FECHAS, MOTIVO = range(3)
|
||||
|
||||
async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
user = update.effective_user
|
||||
log_request(user.id, user.username, "vacaciones", update.message.text)
|
||||
context.user_data['tipo'] = 'Vacaciones'
|
||||
context.user_data['tipo'] = 'VACACIONES'
|
||||
await update.message.reply_text("🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas? (Ej: 10 al 15 de Octubre)")
|
||||
return FECHAS
|
||||
|
||||
async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
user = update.effective_user
|
||||
log_request(user.id, user.username, "permiso", update.message.text)
|
||||
context.user_data['tipo'] = 'Permiso Especial'
|
||||
context.user_data['tipo'] = 'PERMISO'
|
||||
await update.message.reply_text("⏱️ **Solicitud de Permiso**\n\n¿Para qué día y horario lo necesitas?")
|
||||
return FECHAS
|
||||
|
||||
@@ -30,24 +77,62 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
|
||||
datos = context.user_data
|
||||
user = update.effective_user
|
||||
|
||||
# Payload para n8n
|
||||
# Generar payload base
|
||||
payload = {
|
||||
"solicitante": user.full_name,
|
||||
"record_id": str(uuid.uuid4()),
|
||||
"solicitante": {
|
||||
"id_telegram": user.id,
|
||||
"nombre": user.full_name
|
||||
},
|
||||
"tipo_solicitud": datos['tipo'],
|
||||
"fechas": datos['fechas'],
|
||||
"motivo": motivo
|
||||
"fechas_texto_original": datos['fechas'],
|
||||
"motivo_usuario": motivo,
|
||||
"created_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if datos['tipo'] == 'PERMISO':
|
||||
webhook = os.getenv("WEBHOOK_PERMISOS")
|
||||
categoria = classify_reason(motivo)
|
||||
payload["categoria_detectada"] = categoria
|
||||
await update.message.reply_text(f"Categoría detectada → **{categoria}** 🚨")
|
||||
|
||||
elif datos['tipo'] == 'VACACIONES':
|
||||
webhook = os.getenv("WEBHOOK_VACACIONES")
|
||||
metrics = _calculate_vacation_metrics(datos['fechas'])
|
||||
|
||||
if metrics["dias_totales"] > 0:
|
||||
payload["metricas"] = metrics
|
||||
|
||||
dias = metrics["dias_totales"]
|
||||
if dias <= 5:
|
||||
status = "RECHAZADO"
|
||||
mensaje = f"🔴 {dias} días es un periodo muy corto. Las vacaciones deben ser de al menos 6 días."
|
||||
elif 6 <= dias <= 11:
|
||||
status = "REVISION_MANUAL"
|
||||
mensaje = f"🟡 Solicitud de {dias} días recibida. Tu manager la revisará pronto."
|
||||
else: # 12+
|
||||
status = "PRE_APROBADO"
|
||||
mensaje = f"🟢 ¡Excelente planeación! Tu solicitud de {dias} días ha sido pre-aprobada."
|
||||
|
||||
payload["status_inicial"] = status
|
||||
await update.message.reply_text(mensaje)
|
||||
else:
|
||||
# Si no se pudieron parsear las fechas
|
||||
payload["status_inicial"] = "ERROR_FECHAS"
|
||||
await update.message.reply_text("🤔 No entendí las fechas. Por favor, usa un formato como '10 al 15 de Octubre'.")
|
||||
|
||||
try:
|
||||
if webhook:
|
||||
requests.post(webhook, json=payload)
|
||||
await update.message.reply_text(f"✅ Solicitud de *{datos['tipo']}* enviada a tu Manager.")
|
||||
except:
|
||||
tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones'
|
||||
await update.message.reply_text(f"✅ Solicitud de *{tipo_solicitud_texto}* enviada a tu Manager.")
|
||||
except Exception as e:
|
||||
print(f"Error enviando webhook: {e}")
|
||||
await update.message.reply_text("⚠️ Error enviando la solicitud.")
|
||||
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
await update.message.reply_text("Solicitud cancelada.")
|
||||
return ConversationHandler.END
|
||||
|
||||
@@ -3,3 +3,4 @@ python-dotenv
|
||||
requests
|
||||
SQLAlchemy
|
||||
mysql-connector-python
|
||||
google-generativeai
|
||||
Reference in New Issue
Block a user