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 openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) 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): """ Recibe una lista de textos (strings) y la divide en bloques de longitud máxima. """ # df = pd.read_excel(nombre_archivo) # if "texto" not in df.columns: # raise ValueError("❌ La columna 'texto' no está presente en el archivo.") # textos = df["texto"].dropna().astype(str).tolist() texto_plano = "\n".join(textos) bloques = [] while texto_plano: bloque = texto_plano[:max_chars] ultimo_salto = bloque.rfind("\n") if ultimo_salto == -1: ultimo_salto = len(bloque) bloques.append(bloque[:ultimo_salto]) texto_plano = texto_plano[ultimo_salto:].lstrip() 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 element.name == "h1": # doc.add_heading(element.get_text(), level=1) # elif element.name == "h2": # doc.add_heading(element.get_text(), level=2) # elif element.name == "h3": # doc.add_heading(element.get_text(), level=3) # elif element.name in ["ul", "ol"]: # for li in element.find_all("li"): # style = 'List Number' if element.name == "ol" else 'List Bullet' # doc.add_paragraph(li.get_text(), style=style) # elif element.name == "p": # doc.add_paragraph(element.get_text()) # elif element.name == "blockquote": # doc.add_paragraph(element.get_text(), style='Intense Quote') # else: # if hasattr(element, "get_text"): # doc.add_paragraph(element.get_text()) 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 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"): return None, "❌ El archivo debe ser un Excel (.xlsx). Por favor sube un archivo válido." try: df = pd.read_excel(nombre) except Exception as e: return None, f"❌ No se pudo leer el archivo Excel. 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'. """ nombre = archivo.name if not nombre.endswith(".xlsx"): return None, "❌ El archivo debe ser un Excel (.xlsx). Por favor sube un archivo válido." try: df = pd.read_excel(nombre) except Exception as e: return None, f"❌ No se pudo leer el archivo Excel. 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"} 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 from docx import Document 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 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