import os import pandas as pd import re import unicodedata from docx import Document from bs4 import BeautifulSoup import markdown from docx.shared import Pt from docx.oxml.ns import qn from openai import OpenAI from datetime import timedelta openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) def limpiar_nombre_archivo(texto): return re.sub(r'[<>:"/\\|?¿*]', '', texto) def preparar_bloques_json(nombre_archivo, max_chars=100000): """ Prepara bloques de datos desde un archivo Excel para convertirlos a formato JSON. Cada bloque contendrá un máximo de `max_chars` caracteres en total. Args: nombre_archivo (str): Ruta al archivo .xlsx. max_chars (int): Número máximo de caracteres por bloque. Returns: List[Dict]: Lista de bloques en formato JSON {pregunta: [respuestas]}. """ df = pd.read_excel(nombre_archivo) df['count_char'] = df.apply(lambda fila: sum(len(str(val)) for val in fila if pd.notnull(val)), axis=1) bloques, bloque_actual, char_count = [], [], 0 for _, fila in df.iterrows(): if char_count + fila['count_char'] > max_chars: bloques.append(pd.DataFrame(bloque_actual)) bloque_actual, char_count = [], 0 bloque_actual.append(fila) char_count += fila['count_char'] if bloque_actual: bloques.append(pd.DataFrame(bloque_actual)) bloques_json = [] for bloque_df in bloques: bloque_dict = {} for col in bloque_df.columns: if col != 'count_char': respuestas = bloque_df[col].dropna().astype(str).tolist() if respuestas: bloque_dict[col] = respuestas bloques_json.append(bloque_dict) return bloques_json def preparar_bloques_texto_redes(textos, max_chars=120000): """ Divide la lista de textos en bloques semánticos, preservando los posteos individuales. """ bloques = [] bloque_actual = [] chars_actuales = 0 for texto in textos: texto_limpio = texto.strip() if not texto_limpio: continue # +2 por salto de línea chars_posteo = len(texto_limpio) + 2 if chars_actuales + chars_posteo > max_chars: bloques.append("\n".join(bloque_actual)) bloque_actual = [texto_limpio] chars_actuales = chars_posteo else: bloque_actual.append(texto_limpio) chars_actuales += chars_posteo if bloque_actual: bloques.append("\n".join(bloque_actual)) return bloques def markdown_a_word_docx(archivo_md, archivo_docx): """ Convierte un archivo Markdown a un documento Word (.docx). - Elimina espacios excesivos. - Aplica estilos a títulos. - Reduce el espaciado entre párrafos. Parámetros: - archivo_md (str): Ruta al archivo .md. - archivo_docx (str): Ruta de salida para el archivo .docx. """ with open(archivo_md, "r", encoding="utf-8") as f: markdown_text = f.read() html_content = markdown.markdown(markdown_text) soup = BeautifulSoup(html_content, "html.parser") doc = Document() # Ajustar el estilo de los párrafos normales style = doc.styles['Normal'] font = style.font font.name = 'Arial' font.size = Pt(11) for element in soup.children: if not hasattr(element, "name"): continue # Evita texto suelto fuera de etiquetas if element.name == "h1": p = doc.add_heading(element.get_text(), level=1) p.paragraph_format.space_after = Pt(4) elif element.name == "h2": p = doc.add_heading(element.get_text(), level=2) p.paragraph_format.space_after = Pt(3) elif element.name == "h3": p = doc.add_heading(element.get_text(), level=3) p.paragraph_format.space_after = Pt(2) elif element.name in ["ul", "ol"]: style = 'List Number' if element.name == "ol" else 'List Bullet' for li in element.find_all("li"): p = doc.add_paragraph(li.get_text(strip=True), style=style) p.paragraph_format.space_after = Pt(1) elif element.name == "blockquote": p = doc.add_paragraph(element.get_text(strip=True), style='Intense Quote') p.paragraph_format.space_after = Pt(2) elif element.name == "p": text = element.get_text(strip=True) if text: p = doc.add_paragraph(text) p.paragraph_format.space_after = Pt(2) doc.save(archivo_docx) def validar_y_leer_excel_encuesta(archivo): """ Valida que el archivo sea un Excel o CSV correcto y que tenga una estructura mínima válida. Devuelve el DataFrame si es válido o una tupla (None, mensaje de error) si no lo es. """ nombre = archivo.name if not (nombre.endswith(".xlsx") or nombre.endswith(".csv")): return None, "❌ El archivo debe ser un Excel (.xlsx) o CSV (.csv). Por favor, sube un archivo válido." try: if nombre.endswith(".xlsx"): df = pd.read_excel(archivo) else: df = pd.read_csv(archivo, sep=";") except Exception as e: return None, f"❌ No se pudo leer el archivo. Verifica que esté correctamente guardado. Detalle: {str(e)}" if df.shape[1] < 2: return None, "❌ El archivo debe tener al menos 2 columnas (cada una correspondiente a una pregunta distinta)." if not all(isinstance(col, str) and col.strip() != "" for col in df.columns): return None, "❌ El archivo debe tener encabezados válidos en las columnas (nombres de las preguntas)." if df.dropna(how="all").empty: return None, "❌ El archivo está vacío o no contiene respuestas válidas." return df, None def validar_y_leer_excel_redes(archivo): """ Valida que el archivo de redes sociales sea un Excel correcto con las columnas obligatorias: 'red_social', 'fecha', 'autor', 'texto'. """ try: # Detectar si es ruta o archivo Gradio if isinstance(archivo, str): path = archivo else: path = archivo.name if not (path.endswith(".xlsx") or path.endswith(".csv") or path.endswith(".pkl")): return None, "❌ El archivo debe ser un .xlsx, .csv o .pkl. Por favor, sube un archivo válido o consulta la base." # Leer el archivo según tipo if path.endswith(".xlsx"): df = pd.read_excel(archivo) elif path.endswith(".csv"): df = pd.read_csv(archivo, sep=";", encoding="utf-8") # o sep=";" si sabés que el CSV viene así elif path.endswith(".pkl"): df = pd.read_pickle(path) else: return None, "❌ Formato no soportado." except Exception as e: return None, f"❌ No se pudo leer el archivo. Verifica que esté correctamente guardado. Detalle: {str(e)}" if df.shape[1] < 4: return None, "❌ El archivo debe tener al menos 4 columnas." if not all(isinstance(col, str) and col.strip() != "" for col in df.columns): return None, "❌ El archivo debe tener encabezados válidos en las columnas." if df.dropna(how="all").empty: return None, "❌ El archivo está vacío o no contiene datos válidos." columnas_requeridas = {"red_social", "fecha", "autor", "texto", "serie"} columnas_presentes = set(col.lower().strip() for col in df.columns) if not columnas_requeridas.issubset(columnas_presentes): faltantes = columnas_requeridas - columnas_presentes return None, f"❌ Faltan columnas obligatorias: {', '.join(faltantes)}" return df, None def combinar_archivos_docx(lista_archivos, salida): documento_final = Document() # Eliminar contenido inicial de forma segura body = documento_final.element.body for _ in range(len(body)): body.remove(body[0]) for i, archivo in enumerate(lista_archivos): doc_temp = Document(archivo) # Verificar si el documento no está vacío tiene_contenido = any( p.text.strip() for p in doc_temp.paragraphs if p.text ) # if i != 0 and tiene_contenido: # documento_final.add_page_break() for elemento in doc_temp.element.body: # Evitar agregar elementos vacíos if elemento.tag.endswith('p'): texto = ''.join([n.text for n in elemento.iter() if n.text]) if not texto.strip(): continue documento_final.element.body.append(elemento) documento_final.save(salida) def extraer_texto_de_docx(ruta): doc = Document(ruta) return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) def clean_text(text): if not isinstance(text, str): return "" # Quitar 'RT' text = re.sub(r'^RT[\s]+', '', text) # Quitar URLs text = re.sub(r'https?:\/\/\S+', '', text) # Quitar menciones text = re.sub(r'@\w+', '', text) # Quitar hashtags (opcional) # text = re.sub(r'#\w+', '', text) # Eliminar emojis y símbolos no ASCII # text = text.encode('ascii', 'ignore').decode('ascii') # Normalizar acentos y caracteres especiales text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("utf-8") # Quitar puntuación # text = re.sub(r'[^\w\s]', '', text) # Convertir a minúsculas text = text.lower() # Eliminar espacios extra text = re.sub(r'\s+', ' ', text).strip() return text def generar_resumenes_por_red_social(red_social, textos, prompts, modelo="gpt-4o-mini"): bloques = preparar_bloques_texto_redes(textos) resumenes_por_prompt = {} for clave, prompt_info in prompts.items(): resumenes_bloques = [] for i, bloque in enumerate(bloques, start=1): try: response = openai.chat.completions.create( model=modelo, messages=[ {"role": "system", "content": prompt_info["system"]}, {"role": "user", "content": prompt_info["user"] + "\n\n```json\n" + str(bloque) + "\n```"} ], temperature=0.4 ) resumen = response.choices[0].message.content except Exception as e: resumen = f"⚠️ Error en bloque {i}: {str(e)}" resumenes_bloques.append(f"\n\n🧩 **Resumen bloque {i}/{len(bloques)} ({prompt_info['nombre']})**\n{'-'*80}\n{resumen}") # Concatenar todos los bloques como resumen global de este prompt resumenes_por_prompt[clave] = "\n".join(resumenes_bloques) return resumenes_por_prompt def sintetizar_resumenes_por_pregunta(resumenes_por_clave: dict[str, str], nombre_informe: str) -> str: prompt_sintesis = f""" Eres un analista político especializado en procesamiento de respuestas abiertas de encuestas. A continuación te presento los análisis por pregunta generados a partir de respuestas ciudadanas. Tu tarea es generar un **informe final estructurado por pregunta**, donde: - Se mantenga el título de cada pregunta (como encabezado). - Se **eviten repeticiones entre preguntas**: si un tema ya fue desarrollado en una pregunta anterior, **no lo repitas**, solo hacé una mención breve si es necesario. - Se resalte **lo más representativo de cada pregunta**, evitando duplicar argumentos o emociones ya abordadas. - Se incluya contenido relevante, bien organizado y con ejemplos si aplica, pero sin extender innecesariamente. Tu análisis debe ser profesional, claro y sin redundancias. Comenzá directamente con el desarrollo estructurado por pregunta. No incluyas introducción ni conclusión general. """ contenido_completo = "\n\n".join( f"## {titulo}\n\n{contenido}" for titulo, contenido in resumenes_por_clave.items() ) messages = [ {"role": "system", "content": "Eres un experto en redacción de informes profesionales."}, {"role": "user", "content": prompt_sintesis + "\n\n" + contenido_completo} ] try: response = openai.chat.completions.create( model="gpt-4o", messages=messages, temperature=0.4 ) return response.choices[0].message.content except Exception as e: return f"⚠️ Error al sintetizar los resúmenes: {str(e)}" def ajustar_fechas_por_timezone(cliente, start, end): desfase_horario = { "HN": -6, "AR": -3, "VE": -4, "MX": -6, "CO": -5, "PE": -5 } codigo = cliente[:2].upper() if codigo in desfase_horario: delta = timedelta(hours=desfase_horario[codigo]) return start - delta, end - delta, delta else: delta = timedelta(0) # sin desfase return start, end, delta def cargar_prompt(path): with open(path, "r", encoding="utf-8") as f: return f.read()