File size: 21,065 Bytes
8b18f87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
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"))

@dataclass
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
    )