odelgi0810
ChatInterface
b09979b
Raw
History Blame Contribute Delete
13.4 kB
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()