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