|
|
import os |
|
|
import re |
|
|
import time |
|
|
import json |
|
|
import requests |
|
|
import gradio as gr |
|
|
import pandas as pd |
|
|
import torch |
|
|
import logging |
|
|
from dotenv import load_dotenv |
|
|
from transformers import pipeline, AutoTokenizer |
|
|
import sys |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
|
|
|
MODEL_NAME = "Recognai/zeroshot_selectra_medium" |
|
|
CATEGORIAS = { |
|
|
"logística": re.compile(r"pedido|entrega|env[íi]o|llegada|reparto|transporte|seguimiento", re.IGNORECASE), |
|
|
"pagos": re.compile(r"pago|tarjeta|factura|cobro|d[eé]bito|cr[eé]dito|transacci[oó]n", re.IGNORECASE), |
|
|
"producto defectuoso": re.compile(r"defectuoso|roto|dañado|mal estado|no funciona|averiado|falla", re.IGNORECASE), |
|
|
"cuenta": re.compile(r"cuenta|login|registro|acceso|contraseña|usuario|perfil", re.IGNORECASE), |
|
|
"facturación": re.compile(r"factura|recibo|impuesto|cargo|precio|valor|subtotal", re.IGNORECASE) |
|
|
} |
|
|
URGENCY_PATTERNS = [ |
|
|
r"\b(urgente|inmediato|cr[íi]tico|asap|necesito ayuda ya)\b", |
|
|
r"\b(no funciona|error|fallo|roto|averiado|defectuoso|no sirve)\b", |
|
|
r"!\s*!+", |
|
|
r"\b(prioridad [1-3]|nivel [1-3])\b" |
|
|
] |
|
|
|
|
|
|
|
|
class TicketSystem: |
|
|
def limpiar_historial(self, filename="tickets_db.json"): |
|
|
"""Limpia el historial de tickets simulados.""" |
|
|
self.tickets = [] |
|
|
self.next_id = 1000 |
|
|
self.save_to_json(filename) |
|
|
return True |
|
|
def __init__(self): |
|
|
self.mode = os.getenv("TICKET_API_MODE", "simulated") |
|
|
self.tickets = [] |
|
|
self.next_id = 1000 |
|
|
|
|
|
def create_ticket(self, description: str, category: str, urgent: bool): |
|
|
"""Crea un ticket en Zendesk o modo simulado""" |
|
|
if self.mode == "zendesk": |
|
|
return self._create_zendesk_ticket(description, category, urgent) |
|
|
else: |
|
|
return self._create_simulated_ticket(description, category, urgent) |
|
|
|
|
|
def _create_simulated_ticket(self, description: str, category: str, urgent: bool): |
|
|
ticket = { |
|
|
"id": self.next_id, |
|
|
"description": description, |
|
|
"category": category, |
|
|
"urgent": urgent, |
|
|
"status": "open", |
|
|
"assigned_to": "Agente Humano" if urgent else "Sistema Automático", |
|
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"), |
|
|
"source": "Simulado" |
|
|
} |
|
|
self.tickets.append(ticket) |
|
|
self.next_id += 1 |
|
|
self.save_to_json() |
|
|
return ticket |
|
|
|
|
|
def _create_zendesk_ticket(self, description: str, category: str, urgent: bool): |
|
|
"""Crea un ticket real en Zendesk""" |
|
|
subdomain = os.getenv("ZENDESK_SUBDOMAIN") |
|
|
email = os.getenv("ZENDESK_EMAIL") |
|
|
api_token = os.getenv("ZENDESK_API_TOKEN") |
|
|
|
|
|
priority = "urgent" if urgent else "normal" |
|
|
subject = f"[{category}] {'[URGENTE] ' if urgent else ''}Ticket Automático" |
|
|
|
|
|
data = { |
|
|
"ticket": { |
|
|
"subject": subject, |
|
|
"comment": {"body": description}, |
|
|
"priority": priority, |
|
|
"tags": ["auto_classified", category], |
|
|
"type": "problem" |
|
|
} |
|
|
} |
|
|
|
|
|
try: |
|
|
response = requests.post( |
|
|
f"https://{subdomain}.zendesk.com/api/v2/tickets.json", |
|
|
json=data, |
|
|
auth=(f"{email}/token", api_token), |
|
|
headers={"Content-Type": "application/json"} |
|
|
) |
|
|
|
|
|
if response.status_code == 201: |
|
|
ticket_data = response.json().get("ticket", {}) |
|
|
ticket = { |
|
|
"id": ticket_data["id"], |
|
|
"description": description, |
|
|
"category": category, |
|
|
"urgent": urgent, |
|
|
"status": ticket_data.get("status", "open"), |
|
|
"assigned_to": "Agente Humano" if urgent else "Sistema Automático", |
|
|
"created_at": ticket_data.get("created_at", time.strftime("%Y-%m-%d %H:%M:%S")), |
|
|
"source": "Zendesk" |
|
|
} |
|
|
self.tickets.append(ticket) |
|
|
self.save_to_json() |
|
|
return ticket |
|
|
else: |
|
|
error_msg = f"Error {response.status_code}: {response.text}" |
|
|
return {"error": error_msg} |
|
|
|
|
|
except Exception as e: |
|
|
return {"error": str(e)} |
|
|
|
|
|
def get_tickets(self): |
|
|
return self.tickets |
|
|
|
|
|
def save_to_json(self, filename="tickets_db.json"): |
|
|
with open(filename, 'w') as f: |
|
|
json.dump(self.tickets, f, indent=2) |
|
|
|
|
|
|
|
|
MODEL_LOADED = False |
|
|
classifier = None |
|
|
|
|
|
try: |
|
|
|
|
|
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) |
|
|
classifier = pipeline( |
|
|
"zero-shot-classification", |
|
|
model=MODEL_NAME, |
|
|
device=0 if torch.cuda.is_available() else -1 |
|
|
) |
|
|
MODEL_LOADED = True |
|
|
logger.info("✅ Modelo en español cargado exitosamente") |
|
|
except Exception as e: |
|
|
logger.error(f"⚠️ Error cargando modelo principal: {e}") |
|
|
logger.info("🔶 Usando modelo alternativo multilingüe...") |
|
|
try: |
|
|
MODEL_NAME = "vicgalle/xlm-roberta-large-xnli-anli" |
|
|
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) |
|
|
classifier = pipeline( |
|
|
"zero-shot-classification", |
|
|
model=MODEL_NAME, |
|
|
device=0 if torch.cuda.is_available() else -1 |
|
|
) |
|
|
MODEL_LOADED = True |
|
|
logger.info("✅ Modelo multilingüe cargado exitosamente") |
|
|
except Exception as alt_e: |
|
|
logger.error(f"⚠️ Error cargando modelo alternativo: {alt_e}") |
|
|
logger.info("🔶 Usando clasificador aleatorio como fallback") |
|
|
|
|
|
|
|
|
def es_urgente(text: str) -> bool: |
|
|
text = text.lower() |
|
|
for pattern in URGENCY_PATTERNS: |
|
|
if re.search(pattern, text, flags=re.IGNORECASE): |
|
|
return True |
|
|
return False |
|
|
|
|
|
def clasificar_con_palabras_clave(text: str) -> str: |
|
|
text_lower = text.lower() |
|
|
for category, pattern in CATEGORIAS.items(): |
|
|
if pattern.search(text_lower): |
|
|
return category |
|
|
return "otros" |
|
|
|
|
|
def clasificar_texto(text: str) -> str: |
|
|
if not MODEL_LOADED or classifier is None: |
|
|
return clasificar_con_palabras_clave(text) |
|
|
|
|
|
try: |
|
|
|
|
|
hypothesis_templates = [ |
|
|
f"Este ticket trata sobre {cat}." for cat in CATEGORIAS |
|
|
] |
|
|
|
|
|
result = classifier( |
|
|
text, |
|
|
candidate_labels=CATEGORIAS, |
|
|
hypothesis_template=hypothesis_templates, |
|
|
multi_label=False |
|
|
) |
|
|
|
|
|
|
|
|
umbrales = { |
|
|
"logística": 0.4, |
|
|
"otros": 0.3, |
|
|
"default": 0.5 |
|
|
} |
|
|
|
|
|
top_label = result['labels'][0] |
|
|
top_score = result['scores'][0] |
|
|
umbral = umbrales.get(top_label, umbrales["default"]) |
|
|
|
|
|
if top_score >= umbral: |
|
|
return top_label |
|
|
|
|
|
|
|
|
return clasificar_con_palabras_clave(text) |
|
|
except Exception as e: |
|
|
logger.error(f"⚠️ Error en clasificación: {e}") |
|
|
return clasificar_con_palabras_clave(text) |
|
|
|
|
|
|
|
|
def procesar_tickets(input_csv, output_csv=None): |
|
|
""" |
|
|
Procesa un archivo CSV con tickets y genera resultados clasificados. |
|
|
- Permite nombres únicos para archivos de salida. |
|
|
- Valida la existencia de la columna 'descripcion' (case-insensitive). |
|
|
""" |
|
|
try: |
|
|
df = pd.read_csv(input_csv) |
|
|
|
|
|
desc_col = None |
|
|
for col in df.columns: |
|
|
if col.strip().lower() == 'descripcion': |
|
|
desc_col = col |
|
|
break |
|
|
if not desc_col: |
|
|
raise ValueError("El CSV debe contener una columna llamada 'descripcion' (no se encontró, revise el encabezado)") |
|
|
|
|
|
|
|
|
num_duplicados = df.duplicated(subset=[desc_col]).sum() |
|
|
if num_duplicados > 0: |
|
|
logger.warning(f"Se encontraron {num_duplicados} tickets duplicados (por descripción) en el archivo CSV.") |
|
|
|
|
|
timestamp = time.strftime("%Y%m%d_%H%M%S") |
|
|
if not output_csv: |
|
|
output_csv = f"tickets_clasificados_{timestamp}.csv" |
|
|
urgentes_csv = f"tickets_urgentes_{timestamp}.csv" |
|
|
|
|
|
categorias_pred = [] |
|
|
urgencias = [] |
|
|
logger.info("Iniciando procesamiento de tickets...") |
|
|
for i, descripcion in enumerate(df[desc_col]): |
|
|
descripcion_str = str(descripcion) |
|
|
categoria = clasificar_texto(descripcion_str) |
|
|
categorias_pred.append(categoria) |
|
|
urgencia = es_urgente(descripcion_str) |
|
|
urgencias.append(urgencia) |
|
|
logger.info(f"Ticket {i+1}: '{descripcion_str[:30]}...' -> Categoría: {categoria}, Urgente: {urgencia}") |
|
|
df['categoria'] = categorias_pred |
|
|
df['urgente'] = urgencias |
|
|
df.to_csv(output_csv, index=False) |
|
|
logger.info(f"Resultados guardados en {output_csv}") |
|
|
urgentes = df[df['urgente']] |
|
|
if not urgentes.empty: |
|
|
urgentes.to_csv(urgentes_csv, index=False) |
|
|
logger.info(f"⚠️ {len(urgentes)} tickets urgentes guardados en '{urgentes_csv}'") |
|
|
return df, urgentes_csv, output_csv, len(df), len(urgentes), num_duplicados |
|
|
else: |
|
|
logger.info("No se encontraron tickets urgentes") |
|
|
return df, None, output_csv, len(df), 0, num_duplicados |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Error procesando CSV: {e}") |
|
|
raise |
|
|
|
|
|
|
|
|
|
|
|
ticket_system = TicketSystem() |
|
|
|
|
|
|
|
|
def procesar_ticket_individual(text): |
|
|
if not text.strip(): |
|
|
return "", "", "" |
|
|
|
|
|
categoria = clasificar_texto(text) |
|
|
urgente = es_urgente(text) |
|
|
|
|
|
|
|
|
ticket = ticket_system.create_ticket(text, categoria, urgente) |
|
|
|
|
|
status = "🔴 URGENTE - Asignado a Agente Humano" if urgente else "🟢 Enviado a Sistema Automático" |
|
|
|
|
|
return categoria, "SÍ" if urgente else "NO", status |
|
|
|
|
|
|
|
|
with gr.Blocks(title="Sistema de Soporte Inteligente", theme=gr.themes.Soft()) as demo: |
|
|
gr.Markdown("# 🚀 Sistema Clasificador de Tickets") |
|
|
gr.Markdown(f"**Modo actual:** `{ticket_system.mode.upper()}` | **Modelo:** `{MODEL_NAME if MODEL_LOADED else 'ALEATORIO'}`") |
|
|
|
|
|
|
|
|
""" |
|
|
with gr.Tab("Clasificación Individual"): |
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
input_text = gr.Textbox( |
|
|
label="Descripción del problema", |
|
|
placeholder="Escribe aquí el problema del cliente...", |
|
|
lines=4 |
|
|
) |
|
|
submit_btn = gr.Button("Procesar Ticket", variant="primary") |
|
|
|
|
|
with gr.Accordion("Ejemplos Rápidos", open=False): |
|
|
gr.Examples( |
|
|
examples=[ |
|
|
["Mi paquete no llegó a tiempo, ¡es urgente!"], |
|
|
["Error 500 al procesar mi tarjeta de crédito"], |
|
|
["El producto llegó con la pantalla rota"], |
|
|
["No puedo acceder a mi cuenta premium"], |
|
|
["Factura con impuestos incorrectos"] |
|
|
], |
|
|
inputs=[input_text] |
|
|
) |
|
|
|
|
|
with gr.Column(): |
|
|
categoria_out = gr.Textbox(label="Categoría") |
|
|
urgencia_out = gr.Textbox(label="¿Urgente?") |
|
|
status_out = gr.Textbox(label="Estado del Ticket") |
|
|
|
|
|
with gr.Accordion("Base de Tickets", open=False): |
|
|
ticket_db = gr.JSON(label="Tickets Registrados") |
|
|
update_btn = gr.Button("Actualizar Base de Datos") |
|
|
""" |
|
|
|
|
|
with gr.Tab("Procesar Archivo CSV"): |
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
file_input = gr.File(label="Subir CSV de tickets", file_types=[".csv"]) |
|
|
process_btn = gr.Button("Procesar Archivo", variant="primary") |
|
|
|
|
|
with gr.Column(): |
|
|
output_status = gr.Textbox(label="Estado de Procesamiento") |
|
|
output_download = gr.File(label="Descargar Resultados") |
|
|
urgent_download = gr.File(label="Descargar Tickets Urgentes", visible=False) |
|
|
|
|
|
with gr.Accordion("Instrucciones", open=False): |
|
|
gr.Markdown(""" |
|
|
**Formato CSV requerido:** |
|
|
- Debe contener columna 'descripcion' |
|
|
- Ejemplo: |
|
|
``` |
|
|
id,descripcion |
|
|
1,Mi pedido no llegó |
|
|
2,Error en mi pago |
|
|
``` |
|
|
""") |
|
|
|
|
|
|
|
|
""" |
|
|
submit_btn.click( |
|
|
fn=procesar_ticket_individual, |
|
|
inputs=input_text, |
|
|
outputs=[categoria_out, urgencia_out, status_out] |
|
|
) |
|
|
|
|
|
update_btn.click( |
|
|
fn=lambda: ticket_system.get_tickets(), |
|
|
inputs=[], |
|
|
outputs=ticket_db |
|
|
) |
|
|
""" |
|
|
|
|
|
|
|
|
def procesar_csv_wrapper(archivo): |
|
|
""" |
|
|
Procesa el archivo CSV subido y retorna mensajes y archivos de salida únicos. |
|
|
""" |
|
|
if archivo is None: |
|
|
return "❌ No se subió ningún archivo", None, None, gr.update(visible=False) |
|
|
try: |
|
|
file_path = archivo.name |
|
|
result, urgentes_file, output_file, total, urgentes_count, duplicados = procesar_tickets(file_path) |
|
|
resumen = f"Total tickets procesados: {total}. " |
|
|
if duplicados > 0: |
|
|
resumen += f"Duplicados detectados: {duplicados}. " |
|
|
if urgentes_count > 0: |
|
|
resumen += f"Tickets urgentes: {urgentes_count}. " |
|
|
else: |
|
|
resumen += "No se encontraron tickets urgentes. " |
|
|
if result is not None: |
|
|
if urgentes_file: |
|
|
return ( |
|
|
f"✅ Procesamiento completado con éxito. {resumen}Resultados: {output_file}", |
|
|
output_file, |
|
|
urgentes_file, |
|
|
gr.update(visible=True) |
|
|
) |
|
|
else: |
|
|
return ( |
|
|
f"✅ Procesamiento completado. {resumen}Resultados: {output_file}", |
|
|
output_file, |
|
|
None, |
|
|
gr.update(visible=False) |
|
|
) |
|
|
else: |
|
|
return "❌ Error procesando el archivo", None, None, gr.update(visible=False) |
|
|
except Exception as e: |
|
|
return f"❌ Error: {str(e)}", None, None, gr.update(visible=False) |
|
|
|
|
|
process_btn.click( |
|
|
fn=procesar_csv_wrapper, |
|
|
inputs=file_input, |
|
|
outputs=[output_status, output_download, urgent_download, urgent_download] |
|
|
) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
|
if len(sys.argv) > 1: |
|
|
input_csv = sys.argv[1] |
|
|
logger.info(f"Procesando archivo: {input_csv}") |
|
|
|
|
|
try: |
|
|
result, urgentes, salida, total, urgentes_count, duplicados = procesar_tickets(input_csv) |
|
|
logger.info(f"Total tickets procesados: {total}") |
|
|
logger.info(f"Duplicados detectados: {duplicados}") |
|
|
logger.info(f"Tickets urgentes: {urgentes_count}") |
|
|
logger.info(f"Archivo de resultados: {salida}") |
|
|
|
|
|
if urgentes: |
|
|
logger.info(f"Archivo de tickets urgentes: {urgentes}") |
|
|
else: |
|
|
logger.info("No se encontraron tickets urgentes.") |
|
|
except Exception as e: |
|
|
logger.error(f"Error procesando el archivo: {e}") |
|
|
sys.exit(1) |
|
|
else: |
|
|
|
|
|
demo.launch( |
|
|
server_name="0.0.0.0", |
|
|
server_port=7860, |
|
|
show_error=True |
|
|
) |
|
|
|