feat: Implement AI-powered permit reason classification and add comprehensive project documentation.

This commit is contained in:
Marco Gallegos
2025-12-14 10:04:34 -06:00
parent 7a87a010ae
commit c0793db73c
8 changed files with 443 additions and 25 deletions

View File

@@ -1,10 +1,16 @@
# Configuración de Telegram # Configuración de Telegram
TELEGRAM_TOKEN=TU_TOKEN_NUEVO_AQUI 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) # Webhooks de n8n (puedes agregar más aquí en el futuro)
WEBHOOK_CONTRATO= WEBHOOK_CONTRATO=url
WEBHOOK_PRINT= WEBHOOK_PRINT=url
WEBHOOK_VACACIONES= WEBHOOK_VACACIONES=url
WEBHOOK_PERMISOS=url
WEBHOOK_DATA_EMPLEADO=url
# --- DATABASE --- # --- DATABASE ---
# Usado por el servicio de la base de datos en docker-compose.yml # 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_PORT=993
IMAP_USER=your_email@example.com IMAP_USER=your_email@example.com
IMAP_PASSWORD=your_password IMAP_PASSWORD=your_password
PRINTER_EMAIL=your_email@example.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

277
Vanessa.md Normal file
View File

@@ -0,0 +1,277 @@
# 👩‍💼 Vanessa Bot Brain
### Vanity / Soul — HR Automation
![Python](https://img.shields.io/badge/Python-3.11%2B-blue)
![Telegram](https://img.shields.io/badge/Telegram-Bot-blue)
![AI](https://img.shields.io/badge/AI-OpenAI%20%7C%20Gemini-green)
![n8n](https://img.shields.io/badge/Integration-n8n-red)
**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. 🧠

View File

@@ -7,8 +7,6 @@ services:
restart: always restart: always
env_file: env_file:
- .env - .env
environment:
- DATABASE_URL=mysql+mysqlconnector://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE}
depends_on: depends_on:
- db - db
volumes: volumes:

41
modules/ai.py Normal file
View 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

View File

@@ -8,13 +8,22 @@ import logging
# Configuración de logging # Configuración de logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 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 # Construir la URL de la base de datos desde las variables de entorno individuales
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+mysqlconnector://user:password@db:3306/vanessa_logs") 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 # Crear el motor de la base de datos
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
metadata = MetaData() 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 para los modelos declarativos
Base = declarative_base() Base = declarative_base()

View File

@@ -1,22 +1,69 @@
import os import os
import re
import requests 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 telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
from modules.database import log_request 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: async def start_vacaciones(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user = update.effective_user user = update.effective_user
log_request(user.id, user.username, "vacaciones", update.message.text) 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)") await update.message.reply_text("🌴 **Solicitud de Vacaciones**\n\n¿Para qué fechas las necesitas? (Ej: 10 al 15 de Octubre)")
return FECHAS return FECHAS
async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def start_permiso(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user = update.effective_user user = update.effective_user
log_request(user.id, user.username, "permiso", update.message.text) 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?") await update.message.reply_text("⏱️ **Solicitud de Permiso**\n\n¿Para qué día y horario lo necesitas?")
return FECHAS return FECHAS
@@ -30,24 +77,62 @@ async def recibir_motivo_fin(update: Update, context: ContextTypes.DEFAULT_TYPE)
datos = context.user_data datos = context.user_data
user = update.effective_user user = update.effective_user
# Payload para n8n # Generar payload base
payload = { payload = {
"solicitante": user.full_name, "record_id": str(uuid.uuid4()),
"solicitante": {
"id_telegram": user.id, "id_telegram": user.id,
"nombre": user.full_name
},
"tipo_solicitud": datos['tipo'], "tipo_solicitud": datos['tipo'],
"fechas": datos['fechas'], "fechas_texto_original": datos['fechas'],
"motivo": motivo "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") 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: try:
if webhook:
requests.post(webhook, json=payload) requests.post(webhook, json=payload)
await update.message.reply_text(f"✅ Solicitud de *{datos['tipo']}* enviada a tu Manager.") tipo_solicitud_texto = "Permiso" if datos['tipo'] == 'PERMISO' else 'Vacaciones'
except: 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.") await update.message.reply_text("⚠️ Error enviando la solicitud.")
return ConversationHandler.END return ConversationHandler.END
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("Solicitud cancelada.") await update.message.reply_text("Solicitud cancelada.")
return ConversationHandler.END return ConversationHandler.END

View File

@@ -3,3 +3,4 @@ python-dotenv
requests requests
SQLAlchemy SQLAlchemy
mysql-connector-python mysql-connector-python
google-generativeai