Spaces:
Sleeping
Sleeping
| 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 | |