Spaces:
Sleeping
Sleeping
| 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 | |
| ) |