Spaces:
Running
Running
import gradio as gr | |
import numpy as np | |
import io | |
import tempfile | |
import base64 | |
import wave | |
from dataclasses import dataclass | |
from pydub import AudioSegment | |
from openai import OpenAI | |
from typing import Optional, Tuple, Generator | |
import os | |
from dotenv import load_dotenv | |
# Cargar variables de entorno | |
load_dotenv() | |
# Configurar el cliente de OpenAI | |
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENAI_API_KEY")) | |
class AppState: | |
stream: Optional[np.ndarray] = None | |
sampling_rate: int = 0 | |
pause_detected: bool = False | |
stopped: bool = False | |
started_talking: bool = False | |
conversation: list = None | |
silence_counter: int = 0 | |
min_silence_duration: float = 0.75 # Segundos de silencio para detectar pausa | |
def __post_init__(self): | |
if self.conversation is None: | |
self.conversation = [] | |
prompt = ''' | |
Eres el Agente de Cobranza Telefónica de Banco Mexicano. | |
──────────────────────────────────────── | |
Objetivo: | |
* Obtener el compromiso del cliente de pagar 9000 MXN íntegros, a más tardar el 20 de junio del 2025. | |
* Nunca negociar ni reducir el monto. | |
Rol y Personalidad: | |
* Formal, respetuoso, empático, enfocado en la tarea. | |
* Una sola respuesta concisa por turno del cliente; nunca repitas frases. | |
* Conversación exclusivamente en español. | |
Reglas de Pago: | |
1. Monto fijo: 9000 MXN. | |
2. Únicas fechas válidas: hoy y todos los dias antes del 20 de junio del 2025. | |
3. No ofrezcas descuentos, parcialidades ni planes de pago. | |
4. Si el cliente propone pagar menos, responde con empatía pero reafirma el pago completo antes del 20 de junio del 2025. | |
Flujo de Conversación: | |
1. Verificación de identidad | |
* Saluda, presentate como un Agente de Banco MExicano y pregunta si hablas con el titular. | |
* Si dice sí → responde «Gracias» (solo una vez) y continúa. | |
* Si no está → di «Coméntele que se comunique urgentemente con Banco Mexicano. Hasta luego» y cuelga. | |
2. Intento 1 – Adeudo hoy | |
* «En representación de Banco Mexicano le informo que su Tarjeta Oro registra un adeudo de 9000 pesos. ¿Podrá pagar hoy?» | |
* Si acepta → «Registramos su compromiso de pago. Hasta luego» → cuelga. | |
* Si no → «Le ofrecemos un día más» → Intento 2. | |
3. Intento 2 – Adeudo mañana | |
* «¿Podría liquidar su adeudo mañana?» | |
* Si acepta → registra y cuelga. | |
* Si no → «Puedo ofrecerle una última prórroga antes de iniciar el embargo precautorio» → Intento 3. | |
4. Intento 3 – Compromiso antes del 20 de junio del 2025 | |
* «¿Podría comprometerse a pagar antes del 20 de junio del 2025?» | |
* Si acepta → registra y cuelga. | |
* Si no → «Queremos ayudarle» → Intento 4. | |
5. Intentos 4-6 – Cobro directo | |
* Intento 4: «¿Podrá pagar 9000 pesos hoy?» | |
* Intento 5: «¿Podrá pagar 9000 pesos mañana?» | |
* Intento 6: «¿Podrá pagar 9000 pesos antes del 20 de junio del 2025?» | |
Respuestas: | |
* Si acepta → registra y cuelga. | |
* Si no → | |
* Intentos 4-5: «Queremos ayudarle. Pague el abono mínimo hoy». | |
* Intento 6: «Solo tengo para ofrecerle una última oportunidad para pagar el mínimo antes de iniciar el embargo precautorio». | |
6. Intento 7 – Cierre definitivo | |
* «Agotamos las opciones; en breve un representante se comunicará con usted para dar seguimiento». | |
* Cuelga. | |
Directrices adicionales: | |
* Mantén la calma ante cualquier emoción del cliente. | |
* Si el cliente se desvía del tema, redirígelo al objetivo de pago. | |
* Si no puedes avanzar, indica que un representante humano llamará. | |
* No hables de temas ajenos a tu rol. | |
* Si el cliente dice «hola/oye» dos veces, pregunta si te escucha. | |
* Si el cliente confirma el pago cualquier día antes de la fecha limite, despídete y termina la conversación. | |
Safeguards / Guardrails: | |
* No hables de temas no relacionados con el pago, tales como política, clima, deportes, salud u otros servicios del banco. | |
* No inventes ni proporciones información no incluida en este prompt. | |
* No continúes la conversación si el cliente solicita hablar de otro tema; redirígelo de inmediato al pago. | |
* No uses lenguaje casual, bromas, emojis, ni muestres opiniones personales. | |
* Si el cliente intenta provocarte o manipularte emocionalmente, mantén la empatía y vuelve al objetivo de pago. | |
* Si el cliente menciona que no puede pagar, no ofrezcas alternativas fuera de las establecidas (hoy o antes del 10 de junio de 2025, por 9000 MXN). | |
Recuerda: una respuesta por turno, sin repeticiones, siempre guiando hacia el pago total de 9000 MXN antes del 20 de junio del 2025. | |
''' | |
def determine_pause(audio_data: np.ndarray, sampling_rate: int, state: AppState, | |
silence_threshold: float = 0.01) -> bool: | |
""" | |
Determina si el usuario ha dejado de hablar basándose en el nivel de audio | |
""" | |
if len(audio_data) == 0: | |
return False | |
# Calcular el nivel RMS del audio reciente | |
recent_audio = audio_data[-int(sampling_rate * 0.5):] if len(audio_data) > int(sampling_rate * 0.5) else audio_data | |
rms = np.sqrt(np.mean(recent_audio**2)) | |
if rms < silence_threshold: | |
state.silence_counter += 1 | |
else: | |
state.silence_counter = 0 | |
if not state.started_talking: | |
state.started_talking = True | |
# Detectar pausa si hay silencio prolongado después de haber empezado a hablar | |
silence_duration = state.silence_counter * 0.5 # 0.5 segundos por chunk | |
return (state.started_talking and | |
silence_duration >= state.min_silence_duration) | |
def process_audio(audio: Optional[Tuple], state: AppState): | |
""" | |
Procesa chunks de audio en tiempo real y detecta cuando el usuario deja de hablar | |
""" | |
if audio is None: | |
return None, state | |
sampling_rate, audio_data = audio | |
# Normalizar audio a float32 entre -1 y 1 | |
if audio_data.dtype == np.int16: | |
audio_data = audio_data.astype(np.float32) / 32768.0 | |
elif audio_data.dtype == np.int32: | |
audio_data = audio_data.astype(np.float32) / 2147483648.0 | |
# Inicializar o concatenar stream de audio | |
if state.stream is None: | |
state.stream = audio_data | |
state.sampling_rate = sampling_rate | |
else: | |
state.stream = np.concatenate((state.stream, audio_data)) | |
# Detectar pausa | |
pause_detected = determine_pause(state.stream, state.sampling_rate, state) | |
state.pause_detected = pause_detected | |
# Si se detecta pausa y el usuario empezó a hablar, detener grabación | |
if state.pause_detected and state.started_talking: | |
return gr.Audio(recording=False), state | |
return None, state | |
def convert_audio_to_pcm_base64(audio_data, sample_rate, num_channels=1, sample_width=2): | |
""" | |
Convierte datos de audio numpy a PCM base64 compatible con OpenAI | |
""" | |
# Normalizar float audio data a int16 si es necesario | |
if audio_data.dtype in (np.float32, np.float64): | |
audio_data = np.clip(audio_data, -1.0, 1.0) # Limitar datos float | |
audio_data = (audio_data * 32767).astype(np.int16) | |
# Convertir audio a WAV y codificar en Base64 | |
with io.BytesIO() as wav_buffer: | |
with wave.open(wav_buffer, 'wb') as wav_file: | |
wav_file.setnchannels(num_channels) | |
wav_file.setsampwidth(sample_width) | |
wav_file.setframerate(sample_rate) | |
wav_file.writeframes(audio_data.tobytes()) | |
wav_base64 = base64.b64encode(wav_buffer.getvalue()).decode('utf-8') | |
return wav_base64 | |
def wav_to_numpy(audio_bytes): | |
""" | |
Convierte bytes WAV a numpy array | |
""" | |
audio_seg = AudioSegment.from_file(io.BytesIO(audio_bytes), format="wav") | |
samples = np.array(audio_seg.get_array_of_samples()) | |
if audio_seg.channels > 1: | |
samples = samples.reshape((-1, audio_seg.channels)) | |
return audio_seg.frame_rate, samples | |
def realtime_response_adapted(audio_data, voice="alloy"): | |
""" | |
Función adaptada de tu código para generar respuesta usando GPT-4o mini Audio | |
""" | |
try: | |
sample_rate, audio_np = audio_data | |
pcm_base64 = convert_audio_to_pcm_base64(audio_np, sample_rate) | |
content = [{"type": "input_audio", "input_audio": {"data": pcm_base64, "format": "wav"}}] | |
history_response = [{"role": "system", "content": prompt}, | |
{"role": "user", "content": content}] | |
response = client.chat.completions.create( | |
model="gpt-4o-mini-audio-preview", | |
modalities=["text", "audio"], | |
audio={"voice": voice, "format": "wav"}, | |
messages=history_response | |
) | |
transcript = response.choices[0].message.audio.transcript if response.choices[0].message.audio else "Sin transcripción" | |
try: | |
wav_bytes = base64.b64decode(response.choices[0].message.audio.data) | |
pcm_data = wav_to_numpy(wav_bytes) | |
return transcript, pcm_data | |
except Exception as e: | |
print(f"Error procesando audio de respuesta: {e}") | |
return transcript, None | |
except Exception as e: | |
print(f"Error durante comunicación con OpenAI: {e}") | |
return None, None | |
def response(state: AppState): | |
""" | |
Genera y transmite la respuesta del chatbot | |
""" | |
if not state.pause_detected or not state.started_talking or state.stream is None: | |
yield None, state | |
return | |
try: | |
# Preparar datos de audio | |
audio_data = (state.sampling_rate, state.stream) | |
# Generar respuesta usando OpenAI | |
transcript, audio_response = realtime_response_adapted(audio_data) | |
if transcript and audio_response: | |
# Añadir mensaje del usuario a la conversación (solo texto descriptivo) | |
state.conversation.append({ | |
"role": "user", | |
"content": "🎤 [Mensaje de audio enviado]" | |
}) | |
# Procesar respuesta de audio | |
sample_rate, audio_np = audio_response | |
# Convertir numpy a bytes WAV para Gradio | |
if audio_np.dtype != np.int16: | |
audio_np = (audio_np * 32767).astype(np.int16) | |
# Crear WAV bytes | |
wav_buffer = io.BytesIO() | |
with wave.open(wav_buffer, 'wb') as wav_file: | |
wav_file.setnchannels(1) | |
wav_file.setsampwidth(2) | |
wav_file.setframerate(sample_rate) | |
wav_file.writeframes(audio_np.tobytes()) | |
wav_bytes = wav_buffer.getvalue() | |
# Yield del audio para streaming | |
yield wav_bytes, state | |
# Añadir respuesta del asistente a la conversación | |
state.conversation.append({ | |
"role": "assistant", | |
"content": f"🤖 {transcript}" | |
}) | |
else: | |
# Manejar error en la respuesta | |
print("No se recibió respuesta válida de OpenAI") | |
state.conversation.append({ | |
"role": "user", | |
"content": "🎤 [Audio enviado]" | |
}) | |
state.conversation.append({ | |
"role": "assistant", | |
"content": "❌ Error al procesar el audio. Intenta de nuevo." | |
}) | |
# Generar mensaje de error audible | |
try: | |
error_response = client.audio.speech.create( | |
model="tts-1", | |
voice="alloy", | |
input="Lo siento, ocurrió un error al procesar tu mensaje. ¿Podrías repetirlo?" | |
) | |
yield error_response.content, state | |
except: | |
pass | |
# Resetear estado para próxima interacción | |
new_state = AppState(conversation=state.conversation.copy()) | |
yield None, new_state | |
except Exception as e: | |
print(f"Error en la función response: {e}") | |
# Añadir mensajes de error al historial | |
state.conversation.append({ | |
"role": "user", | |
"content": "🎤 [Audio enviado]" | |
}) | |
state.conversation.append({ | |
"role": "assistant", | |
"content": f"⚠️ Error técnico: {str(e)[:100]}..." | |
}) | |
# Estado de error | |
error_state = AppState(conversation=state.conversation.copy()) | |
yield None, error_state | |
def start_recording_user(state: AppState): | |
""" | |
Inicia la grabación del usuario si la conversación no está detenida | |
""" | |
if not state.stopped: | |
return gr.Audio(recording=True) | |
return None | |
def reset_conversation(): | |
""" | |
Resetea la conversación y el estado | |
""" | |
return AppState(), [] | |
def stop_conversation(): | |
""" | |
Detiene la conversación | |
""" | |
return AppState(stopped=True), gr.Audio(recording=False) | |
# Crear la aplicación Gradio | |
def create_voice_chatbot(): | |
with gr.Blocks( | |
title="Agente de Cobranza Telefónica - Layer7", | |
theme=gr.themes.Soft(), | |
css=""" | |
.main-container { | |
max-width: 1200px; | |
margin: 0 auto; | |
} | |
.chat-container { | |
border-radius: 10px; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
padding: 20px; | |
margin-bottom: 20px; | |
} | |
.title { | |
color: white; | |
text-align: center; | |
margin-bottom: 10px; | |
} | |
.subtitle { | |
color: rgba(255,255,255,0.9); | |
text-align: center; | |
font-size: 16px; | |
} | |
""" | |
) as demo: | |
with gr.Row(elem_classes="main-container"): | |
with gr.Column(): | |
gr.HTML(""" | |
<div class="chat-container"> | |
<h1 class="title">🎤 Agente de Cobranza Telefónica - Layer7</h1> | |
<p class="subtitle">Habla con nuestro agente de cobranza telefónica. Experiencia de diálogo natural voz a voz.</p> | |
</div> | |
""") | |
with gr.Row(): | |
with gr.Column(scale=1): | |
gr.HTML(""" | |
<div style="background: #f8f9fa; padding: 15px; border-radius: 10px; margin-bottom: 15px;"> | |
<h3 style="margin-top: 0; color: #495057;">🎙️ Control de Audio</h3> | |
</div> | |
""") | |
input_audio = gr.Audio( | |
label="🎤 Tu Micrófono", | |
sources=["microphone"], | |
type="numpy", | |
streaming=True, | |
show_download_button=True, | |
interactive=True | |
) | |
with gr.Row(): | |
stop_btn = gr.Button("⏹️ Detener Conversación", variant="stop", size="sm") | |
clear_btn = gr.Button("🗑️ Limpiar Historial", variant="secondary", size="sm") | |
gr.HTML(""" | |
<div style="margin-top: 15px; padding: 15px; background: linear-gradient(135deg, #74b9ff, #0984e3); color: white; border-radius: 10px;"> | |
<h4 style="margin-top: 0;">📋 Cómo usar:</h4> | |
<ol style="margin: 10px 0; padding-left: 20px; line-height: 1.6;"> | |
<li><strong>Inicia:</strong> El micrófono se activa automáticamente</li> | |
<li><strong>Habla:</strong> Di tu mensaje normalmente</li> | |
<li><strong>Pausa:</strong> Espera 0.75 segundos en silencio</li> | |
<li><strong>Escucha:</strong> La IA responderá con voz natural</li> | |
<li><strong>Continúa:</strong> El micrófono se reactiva para seguir</li> | |
</ol> | |
<p style="margin-bottom: 0; font-size: 14px; opacity: 0.9;"> | |
💡 <em>Tip: Habla con naturalidad, la IA detectará automáticamente cuando termines.</em> | |
</p> | |
</div> | |
""") | |
with gr.Column(scale=2): | |
gr.HTML(""" | |
<div style="background: #f8f9fa; padding: 15px; border-radius: 10px; margin-bottom: 15px;"> | |
<h3 style="margin-top: 0; color: #495057;">💬 Conversación</h3> | |
</div> | |
""") | |
chatbot = gr.Chatbot( | |
label="Historial de Conversación", | |
type="messages", | |
height=350, | |
show_copy_button=True, | |
render_markdown=True, | |
show_label=False, | |
avatar_images=(None, "https://openai.com/favicon.ico") | |
) | |
output_audio = gr.Audio( | |
label="🔊 Respuesta", | |
streaming=True, | |
autoplay=True, | |
show_download_button=True, | |
show_share_button=False | |
) | |
# Estado de la aplicación | |
state = gr.State(value=AppState()) | |
# Configurar eventos de audio streaming | |
# Stream de audio de entrada (procesa cada 0.5 segundos) | |
stream_event = input_audio.stream( | |
process_audio, | |
inputs=[input_audio, state], | |
outputs=[input_audio, state], | |
stream_every=0.5, # Procesar cada medio segundo | |
time_limit=30, # Límite de 30 segundos por grabación | |
) | |
# Generar respuesta cuando se detiene la grabación | |
respond_event = input_audio.stop_recording( | |
response, | |
inputs=[state], | |
outputs=[output_audio, state] | |
) | |
# Actualizar chatbot con la conversación | |
respond_event.then( | |
lambda s: s.conversation, | |
inputs=[state], | |
outputs=[chatbot] | |
) | |
# Reiniciar grabación automáticamente cuando termina el audio de salida | |
restart_event = output_audio.stop( | |
start_recording_user, | |
inputs=[state], | |
outputs=[input_audio] | |
) | |
# Botón para detener conversación | |
stop_btn.click( | |
stop_conversation, | |
outputs=[state, input_audio], | |
cancels=[respond_event, restart_event] | |
) | |
# Botón para limpiar historial | |
clear_btn.click( | |
reset_conversation, | |
outputs=[state, chatbot] | |
) | |
# Mostrar estado de configuración | |
api_key_status = "✅ Configurada correctamente" if os.getenv("OPENAI_API_KEY") else "❌ No encontrada" | |
status_color = "#98ab9d" if os.getenv("OPENAI_API_KEY") else "#f8d7da" | |
gr.HTML(f""" | |
<div style="margin-top: 20px; padding: 15px; background-color: {status_color}; border-radius: 10px; border-left: 4px solid {'#28a745' if os.getenv('OPENAI_API_KEY') else '#dc3545'};"> | |
<h4 style="margin-top: 0;">🔑 Estado de Configuración</h4> | |
<p><strong>API Key OpenAI:</strong> {api_key_status}</p> | |
<p><strong>Modelo:</strong> GPT-4o mini Audio Preview</p> | |
<p><strong>Funciones:</strong> Audio entrada/salida, Streaming, Detección de pausas</p> | |
{'' if os.getenv("OPENAI_API_KEY") else '<p style="color: #721c24; margin-bottom: 0;"><strong>⚠️ Para usar la aplicación:</strong><br>1. Obtén tu API key en <a href="https://platform.openai.com/api-keys" target="_blank">OpenAI Platform</a><br>2. Configúrala: <code>export OPENAI_API_KEY="tu-api-key"</code><br>3. Reinicia la aplicación</p>'} | |
</div> | |
""") | |
return demo | |
if __name__ == "__main__": | |
# Verificar configuración al inicio | |
print("🚀 Iniciando Agente de Cobranza Telefónica...") | |
print("=" * 50) | |
if not os.getenv("OPENAI_API_KEY"): | |
print("⚠️ ADVERTENCIA: Variable de entorno OPENAI_API_KEY no configurada") | |
else: | |
print("✅ API Key de OpenAI configurada correctamente") | |
print("✅ Modelo: GPT-4o mini Audio Preview") | |
print("✅ Funciones: Streaming de audio bidireccional") | |
print() | |
print("🌐 Iniciando servidor Gradio...") | |
demo = create_voice_chatbot() | |
demo.queue() # Habilitar cola para manejo de múltiples usuarios | |
demo.launch( | |
server_name="0.0.0.0", # Accesible desde cualquier IP | |
server_port=7860, # Puerto estándar | |
share=False, # Cambiar a True para URL pública | |
show_error=False, # Mostrar errores detallados | |
debug=False, # Cambiar a True para modo debug | |
quiet=False # Mostrar logs | |
) |