Merge pull request #33 from marcogll/feature/flow-engine-implementation-15654864159042246464

Feature/flow engine implementation 15654864159042246464
This commit is contained in:
Marco Gallegos
2025-12-21 01:48:44 -06:00
committed by GitHub
15 changed files with 290 additions and 72 deletions

1
.gitignore vendored
View File

@@ -158,7 +158,6 @@ cython_debug/
.vscode/ .vscode/
# Google Service Account Credentials # Google Service Account Credentials
*.json
!credentials.example.json !credentials.example.json
google_key.json google_key.json

View File

@@ -32,7 +32,9 @@ El sistema sigue un flujo modular:
--- ---
## 📋 Flujos de Trabajo (Features) ## 📋 Flujos de Trabajo Modulares (Features)
El comportamiento del bot se define a través de **flujos de conversación modulares** gestionados por un motor central (`flow_engine.py`). Cada flujo es un archivo `.json` independiente ubicado en `talia_bot/data/flows/`, lo que permite modificar o crear nuevas conversaciones sin alterar el código principal.
### 1. 👑 Gestión Admin (Proyectos & Identidad) ### 1. 👑 Gestión Admin (Proyectos & Identidad)
@@ -118,10 +120,11 @@ IMAP_SERVER=imap.hostinger.com
### 3. Estructura de Datos ### 3. Estructura de Datos
Asegúrate de tener los archivos base en `talia_bot/data/`: Asegúrate de tener los archivos y directorios base en `talia_bot/data/`:
* `servicios.json`: Catálogo de servicios para el RAG de ventas. * `servicios.json`: Catálogo de servicios para el RAG de ventas.
* `credentials.json`: Credenciales de Google Cloud. * `credentials.json`: Credenciales de Google Cloud.
* `users.db`: Base de datos SQLite. * `users.db`: Base de datos SQLite que almacena los roles de los usuarios.
* `flows/`: Directorio que contiene las definiciones de los flujos de conversación en formato JSON. Cada archivo representa una conversación completa para un rol específico.
--- ---
@@ -130,10 +133,11 @@ Asegúrate de tener los archivos base en `talia_bot/data/`:
```text ```text
talia_bot_mg/ talia_bot_mg/
├── talia_bot/ ├── talia_bot/
│ ├── main.py # Entry Point y Router de Identidad │ ├── main.py # Entry Point y dispatcher principal
│ ├── db.py # Gestión de la base de datos │ ├── db.py # Gestión de la base de datos SQLite
│ ├── config.py # Carga de variables de entorno │ ├── config.py # Carga de variables de entorno
│ ├── modules/ │ ├── modules/
│ │ ├── flow_engine.py # Motor de flujos de conversación (lee los JSON)
│ │ ├── identity.py # Lógica de Roles y Permisos │ │ ├── identity.py # Lógica de Roles y Permisos
│ │ ├── llm_engine.py # Cliente OpenAI/Gemini │ │ ├── llm_engine.py # Cliente OpenAI/Gemini
│ │ ├── vikunja.py # API Manager para Tareas │ │ ├── vikunja.py # API Manager para Tareas
@@ -141,7 +145,8 @@ talia_bot_mg/
│ │ ├── printer.py # SMTP/IMAP Loop │ │ ├── printer.py # SMTP/IMAP Loop
│ │ └── sales_rag.py # Lógica de Ventas y Servicios │ │ └── sales_rag.py # Lógica de Ventas y Servicios
│ └── data/ │ └── data/
│ ├── servicios.json # Base de conocimiento │ ├── flows/ # Directorio con los flujos de conversación en JSON
│ ├── servicios.json # Base de conocimiento para ventas
│ ├── credentials.json # Credenciales de Google │ ├── credentials.json # Credenciales de Google
│ └── users.db # Base de datos de usuarios │ └── users.db # Base de datos de usuarios
├── .env.example # Plantilla de variables de entorno ├── .env.example # Plantilla de variables de entorno

View File

@@ -0,0 +1,25 @@
{
"id": "admin_block_agenda",
"role": "admin",
"trigger_button": "🛑 Bloquear Agenda",
"steps": [
{
"step_id": 0,
"variable": "BLOCK_DATE",
"question": "Necesito bloquear la agenda. ¿Para cuándo?",
"options": ["Hoy", "Mañana"]
},
{
"step_id": 1,
"variable": "BLOCK_TIME",
"question": "Dame el horario exacto que necesitas bloquear (ej. 'de 2pm a 4pm').",
"input_type": "text"
},
{
"step_id": 2,
"variable": "BLOCK_TITLE",
"question": "Finalmente, dame una breve descripción o motivo del bloqueo.",
"input_type": "text"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"id": "admin_check_agenda",
"role": "admin",
"trigger_button": "📅 Revisar Agenda",
"steps": [
{
"step_id": 0,
"variable": "AGENDA_TIMEFRAME",
"question": "Consultando el oráculo del tiempo... ⏳",
"options": ["📅 Hoy", "🔮 Mañana"]
},
{
"step_id": 1,
"variable": "AGENDA_ACTION",
"question": "Aquí tienes tu realidad: {CALENDAR_DATA}",
"options": ["✅ Todo bien", "🛑 Bloquear Espacio"]
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "admin_create_nfc_tag",
"role": "admin",
"trigger_button": " Crear Tag NFC",
"steps": [
{
"step_id": 0,
"variable": "NFC_ACTION_TYPE",
"question": "Creemos un nuevo tag NFC. ¿Qué acción quieres que dispare?",
"options": ["Iniciar Flujo", "URL Estática"]
},
{
"step_id": 1,
"variable": "NFC_FLOW_CHOICE",
"question": "Okay, ¿qué flujo debería iniciar este tag?",
"input_type": "dynamic_keyboard_flows"
},
{
"step_id": 2,
"variable": "NFC_CONFIRM",
"question": "Perfecto. Cuando acerques tu teléfono a este tag, se iniciará el flujo '{flow_name}'. Aquí tienes los datos para escribir en el tag: {NFC_DATA}",
"options": ["✅ Hecho"]
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "admin_idea_capture",
"role": "admin",
"trigger_button": "💡 Capturar Idea",
"steps": [
{
"step_id": 0,
"variable": "IDEA_CONTENT",
"question": "Te escucho. 💡 Las ideas vuelan...",
"input_type": "text_or_audio"
},
{
"step_id": 1,
"variable": "IDEA_CATEGORY",
"question": "¿En qué cajón mental guardamos esto?",
"options": ["💰 Negocio", "📹 Contenido", "👤 Personal"]
},
{
"step_id": 2,
"variable": "IDEA_ACTION",
"question": "¿Cuál es el plan de ataque?",
"options": ["✅ Crear Tarea", "📓 Guardar Nota"]
}
]
}

View File

@@ -0,0 +1,13 @@
{
"id": "admin_print_file",
"role": "admin",
"trigger_button": "🖨️ Imprimir",
"steps": [
{
"step_id": 0,
"variable": "UPLOAD_FILE",
"question": "Por favor, envíame el archivo que quieres imprimir.",
"input_type": "document"
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "admin_project_management",
"role": "admin",
"trigger_button": "🏗️ Ver Proyectos",
"steps": [
{
"step_id": 0,
"variable": "PROJECT_SELECT",
"question": "Aquí está el tablero de ajedrez...",
"input_type": "dynamic_keyboard_vikunja_projects"
},
{
"step_id": 1,
"variable": "TASK_SELECT",
"question": "Has seleccionado el proyecto {project_name}. ¿Qué quieres hacer?",
"input_type": "dynamic_keyboard_vikunja_tasks"
},
{
"step_id": 2,
"variable": "ACTION_TYPE",
"question": "¿Cuál es la jugada?",
"options": ["🔄 Actualizar Estatus", "💬 Agregar Comentario"]
},
{
"step_id": 3,
"variable": "UPDATE_CONTENT",
"question": "Adelante. Soy todo oídos.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "client_sales_funnel",
"role": "client",
"trigger_automatic": true,
"steps": [
{
"step_id": 0,
"variable": "CLIENT_NAME",
"question": "Hola. Soy Talia, la mano derecha de Armando. ✨Él está ocupado creando, pero yo soy la puerta de entrada. ¿Con quién tengo el gusto?",
"input_type": "text"
},
{
"step_id": 1,
"variable": "CLIENT_INDUSTRY",
"question": "Mucho gusto, {user_name}. Para entender mejor tus necesidades, ¿cuál es el giro de tu negocio o tu industria?",
"options": ["🍽️ Restaurantes", "🩺 Salud", "🛍️ Retail", "อื่น ๆ"]
},
{
"step_id": 2,
"variable": "IDEA_PITCH",
"question": "Excelente. Ahora, el escenario es tuyo. 🎤 Cuéntame sobre tu proyecto o la idea que tienes en mente. No te guardes nada. Puedes escribirlo o, si prefieres, enviarme una nota de voz.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"id": "crew_print_file",
"role": "crew",
"trigger_button": "🖨️ Imprimir",
"steps": [
{
"step_id": 0,
"variable": "UPLOAD_FILE",
"question": "Claro, envíame el archivo que necesitas imprimir y yo me encargo.",
"input_type": "document"
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "crew_request_time",
"role": "crew",
"trigger_button": "📅 Solicitar Agenda",
"steps": [
{
"step_id": 0,
"variable": "REQUEST_TYPE",
"question": "Para usar la agenda del estudio, necesito que seas preciso.",
"options": ["🎥 Grabación", "🎙️ Locución", "🎬 Edición", "🛠️ Mantenimiento"]
},
{
"step_id": 1,
"variable": "REQUEST_DATE",
"question": "¿Para cuándo necesitas el espacio?",
"options": ["Hoy", "Mañana", "Esta Semana"]
},
{
"step_id": 2,
"variable": "REQUEST_TIME",
"question": "Dame el horario exacto que necesitas (ej. 'de 10am a 2pm').",
"input_type": "text"
},
{
"step_id": 3,
"variable": "REQUEST_JUSTIFICATION",
"question": "Entendido. Antes de confirmar, necesito que me expliques brevemente el plan o el motivo para justificar el bloqueo del espacio. Puedes escribirlo o enviarme un audio.",
"input_type": "text_or_audio"
}
]
}

View File

@@ -0,0 +1,37 @@
{
"id": "crew_secret_onboarding",
"role": "crew",
"trigger_command": "/abracadabra",
"steps": [
{
"step_id": 0,
"variable": "ONBOARD_START",
"question": "Vaya, vaya... Parece que conoces el comando secreto. 🎩. Antes de continuar, necesito saber tu nombre completo.",
"input_type": "text"
},
{
"step_id": 1,
"variable": "ONBOARD_ORIGIN",
"question": "Un placer, {user_name}. ¿Cuál es tu base de operaciones principal?",
"options": ["🏢 Office", "✨ Aura"]
},
{
"step_id": 2,
"variable": "ONBOARD_EMAIL",
"question": "Perfecto. Ahora necesito tu correo electrónico de la empresa.",
"input_type": "text"
},
{
"step_id": 3,
"variable": "ONBOARD_PHONE",
"question": "Y por último, tu número de teléfono.",
"input_type": "text"
},
{
"step_id": 4,
"variable": "ONBOARD_CONFIRM",
"question": "Gracias. He enviado una notificación al Administrador para que apruebe tu acceso. En cuanto lo haga, tendrás acceso completo. ¡Bienvenido a bordo!",
"options": ["✅ Entendido"]
}
]
}

View File

@@ -0,0 +1,22 @@
[
{
"service_name": "Web Development for Restaurants",
"description": "Custom websites and online ordering systems for restaurants, helping you reach more customers and streamline your operations.",
"keywords": ["restaurant", "food", "online ordering", "website", "restaurantes", "comida"]
},
{
"service_name": "Patient Management Systems for Healthcare",
"description": "A secure and efficient software solution for managing patient records, appointments, and billing in medical clinics.",
"keywords": ["healthcare", "medical", "patient", "clinic", "salud", "médico", "pacientes"]
},
{
"service_name": "Content Creation & Social Media Strategy",
"description": "Engaging content packages and social media management to build your brand's online presence and connect with your audience.",
"keywords": ["content creation", "social media", "marketing", "branding", "contenido", "redes sociales"]
},
{
"service_name": "General Business Consulting",
"description": "Strategic consulting to help you optimize business processes, identify growth opportunities, and improve overall performance.",
"keywords": ["business", "consulting", "strategy", "growth", "negocio", "consultoría"]
}
]

View File

@@ -32,18 +32,8 @@ def setup_database():
) )
""") """)
# Create the conversations table for the flow engine
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
user_id INTEGER PRIMARY KEY,
flow_id TEXT NOT NULL,
current_step_id INTEGER NOT NULL,
collected_data TEXT
)
""")
conn.commit() conn.commit()
logger.info("Database setup complete. 'users' and 'conversations' tables are ready.") logger.info("Database setup complete. 'users' table is ready.")
except sqlite3.Error as e: except sqlite3.Error as e:
logger.error(f"Database error during setup: {e}") logger.error(f"Database error during setup: {e}")
finally: finally:

View File

@@ -17,7 +17,6 @@ from telegram.ext import (
# Importamos las configuraciones y herramientas que creamos en otros archivos # Importamos las configuraciones y herramientas que creamos en otros archivos
from talia_bot.config import TELEGRAM_BOT_TOKEN from talia_bot.config import TELEGRAM_BOT_TOKEN
from talia_bot.modules.identity import get_user_role from talia_bot.modules.identity import get_user_role
from talia_bot.modules.flow_engine import FlowEngine
from talia_bot.modules.onboarding import handle_start as onboarding_handle_start from talia_bot.modules.onboarding import handle_start as onboarding_handle_start
from talia_bot.modules.onboarding import get_admin_secondary_menu from talia_bot.modules.onboarding import get_admin_secondary_menu
from talia_bot.modules.agenda import get_agenda from talia_bot.modules.agenda import get_agenda
@@ -63,56 +62,14 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Respondemos al usuario # Respondemos al usuario
await update.message.reply_text(response_text, reply_markup=reply_markup) await update.message.reply_text(response_text, reply_markup=reply_markup)
flow_engine = FlowEngine()
async def universal_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Handles all user interactions (text, callbacks, voice, documents).
Routes them to the flow engine or legacy handlers.
"""
user_id = update.effective_user.id
conversation_state = flow_engine.get_conversation_state(user_id)
if conversation_state:
# User is in an active flow, so we process the response.
response_text = update.message.text if update.message else None
result = flow_engine.handle_response(user_id, response_text)
if result['status'] == 'in_progress':
await update.message.reply_text(result['step']['message'])
elif result['status'] == 'complete':
summary = "\n".join([f"- {key}: {value}" for key, value in result['data'].items()])
await update.message.reply_text(f"Flow '{result['flow_id']}' completado.\n\nResumen:\n{summary}")
else:
await update.message.reply_text(result.get('message', 'Ocurrió un error.'))
else:
# No active flow, check for a callback query to start a new flow or use legacy dispatcher.
if update.callback_query:
query = update.callback_query
await query.answer()
flow_to_start = query.data
# Check if the callback is intended to start a known flow.
if flow_engine.get_flow(flow_to_start):
initial_step = flow_engine.start_flow(user_id, flow_to_start)
if initial_step:
await query.message.reply_text(initial_step['message'])
else:
# Fallback to the old button dispatcher for legacy actions.
await button_dispatcher(update, context)
elif update.message and update.message.text:
# Handle regular text messages that are not part of a flow (e.g., commands).
# For now, we just ignore them if they are not commands.
logger.info(f"Received non-flow text message from {user_id}: {update.message.text}")
async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
""" """
Legacy handler for menu button clicks that are not part of a flow. Esta función maneja los clics en los botones del menú.
Dependiendo de qué botón se presione, ejecuta una acción diferente.
""" """
query = update.callback_query query = update.callback_query
# No need to answer here as it's answered in the universal_handler await query.answer()
logger.info(f"El despachador legacy recibió una consulta: {query.data}") logger.info(f"El despachador recibió una consulta: {query.data}")
response_text = "Acción no reconocida." response_text = "Acción no reconocida."
reply_markup = None reply_markup = None
@@ -134,23 +91,27 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE)
try: try:
if query.data in simple_handlers: if query.data in simple_handlers:
handler = simple_handlers[query.data] handler = simple_handlers[query.data]
logger.info(f"Ejecutando simple_handler para: {query.data}")
if asyncio.iscoroutinefunction(handler): if asyncio.iscoroutinefunction(handler):
response_text = await handler() response_text = await handler()
else: else:
response_text = handler() response_text = handler()
elif query.data in complex_handlers: elif query.data in complex_handlers:
handler = complex_handlers[query.data] handler = complex_handlers[query.data]
logger.info(f"Ejecutando complex_handler para: {query.data}")
if asyncio.iscoroutinefunction(handler): if asyncio.iscoroutinefunction(handler):
response_text, reply_markup = await handler() response_text, reply_markup = await handler()
else: else:
response_text, reply_markup = handler() response_text, reply_markup = handler()
elif query.data.startswith(('approve:', 'reject:')): elif query.data.startswith(('approve:', 'reject:')):
logger.info(f"Ejecutando acción de aprobación: {query.data}")
response_text = handle_approval_action(query.data) response_text = handle_approval_action(query.data)
elif query.data == 'start_create_tag': elif query.data == 'start_create_tag':
response_text = "Para crear un tag, por favor usa el comando /create_tag." response_text = "Para crear un tag, por favor usa el comando /create_tag."
else: else:
logger.warning(f"Consulta no manejada por el despachador: {query.data}") logger.warning(f"Consulta no manejada por el despachador: {query.data}")
await query.edit_message_text(text=response_text)
return
except Exception as exc: except Exception as exc:
logger.exception(f"Error al procesar la acción {query.data}: {exc}") logger.exception(f"Error al procesar la acción {query.data}: {exc}")
response_text = "❌ Ocurrió un error al procesar tu solicitud. Intenta de nuevo." response_text = "❌ Ocurrió un error al procesar tu solicitud. Intenta de nuevo."
@@ -158,7 +119,6 @@ async def button_dispatcher(update: Update, context: ContextTypes.DEFAULT_TYPE)
await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown') await query.edit_message_text(text=response_text, reply_markup=reply_markup, parse_mode='Markdown')
def main() -> None: def main() -> None:
"""Función principal que arranca el bot.""" """Función principal que arranca el bot."""
if not TELEGRAM_BOT_TOKEN: if not TELEGRAM_BOT_TOKEN:
@@ -170,9 +130,10 @@ def main() -> None:
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
schedule_daily_summary(application) schedule_daily_summary(application)
# Legacy ConversationHandlers # El orden de los handlers es crucial para que las conversaciones funcionen.
application.add_handler(create_tag_conv_handler()) application.add_handler(create_tag_conv_handler())
application.add_handler(vikunja_conv_handler()) application.add_handler(vikunja_conv_handler())
conv_handler = ConversationHandler( conv_handler = ConversationHandler(
entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')], entry_points=[CallbackQueryHandler(propose_activity_start, pattern='^propose_activity$')],
states={ states={
@@ -184,13 +145,10 @@ def main() -> None:
) )
application.add_handler(conv_handler) application.add_handler(conv_handler)
# Command Handlers
application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("print", print_handler)) application.add_handler(CommandHandler("print", print_handler))
# Universal Handler for flows and callbacks application.add_handler(CallbackQueryHandler(button_dispatcher))
application.add_handler(CallbackQueryHandler(universal_handler))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, universal_handler))
logger.info("Iniciando Talía Bot...") logger.info("Iniciando Talía Bot...")
application.run_polling() application.run_polling()