feat: Add post-processing functionality for CSV files with intelligent agent integration, including a new GUI tab for user instructions and code generation, and update costs for the new model. Enhance .gitignore to exclude database files.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""Agente inteligente de post-procesamiento de CSV.
|
||||
|
||||
Genera código Python con un LLM para transformar DataFrames según
|
||||
instrucciones del usuario en lenguaje natural.
|
||||
Usa la API de Responses de OpenAI (requerida por modelos codex).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POSTPROCESS_MODEL = "gpt-5.1-codex-mini"
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
Eres un agente experto en transformación de datos con pandas.
|
||||
|
||||
REGLAS ESTRICTAS:
|
||||
1. Recibes una MUESTRA representativa de un CSV y una instrucción del usuario.
|
||||
2. Debes devolver ÚNICAMENTE un bloque de código Python válido dentro de ```python ... ```.
|
||||
3. El código opera sobre una variable `df` que es un `pd.DataFrame` ya cargado.
|
||||
4. El código debe modificar `df` in-place o reasignar a `df`. Al final, `df` debe contener el resultado.
|
||||
5. Solo puedes usar las siguientes librerías (ya importadas): `pd` (pandas), `np` (numpy), `re`, `datetime`.
|
||||
6. NO puedes usar: open, exec, eval, __import__, compile, subprocess, os, sys, requests, ni ninguna forma de I/O o acceso al sistema.
|
||||
7. El código debe ser conciso y eficiente.
|
||||
8. NO incluyas explicaciones fuera del bloque de código.
|
||||
"""
|
||||
|
||||
RETRY_TEMPLATE = """\
|
||||
El código que generaste produjo el siguiente error al ejecutarse:
|
||||
|
||||
CÓDIGO:
|
||||
```python
|
||||
{code}
|
||||
```
|
||||
|
||||
ERROR:
|
||||
{error}
|
||||
|
||||
INSTRUCCIÓN ORIGINAL DEL USUARIO:
|
||||
{instructions}
|
||||
|
||||
MUESTRA DEL CSV:
|
||||
{sample}
|
||||
|
||||
Por favor genera un código corregido que solucione el error. Devuelve ÚNICAMENTE el bloque ```python ... ```.
|
||||
"""
|
||||
|
||||
|
||||
def sample_dataframe(df: pd.DataFrame, n: int = 5) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Genera una muestra inteligente del DataFrame.
|
||||
|
||||
Retorna (sample_text, column_info) donde sample_text es una
|
||||
representación legible y column_info contiene metadatos.
|
||||
"""
|
||||
total_rows = len(df)
|
||||
total_cols = len(df.columns)
|
||||
|
||||
col_info = {
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"columns": {col: str(df[col].dtype) for col in df.columns},
|
||||
}
|
||||
|
||||
used_indices: set[int] = set()
|
||||
sections: List[str] = []
|
||||
|
||||
sections.append(
|
||||
f"Total filas: {total_rows:,} | Total columnas: {total_cols}\n"
|
||||
f"Columnas y tipos: {', '.join(f'{c} ({t})' for c, t in col_info['columns'].items())}"
|
||||
)
|
||||
|
||||
head_n = min(n, total_rows)
|
||||
head_idx = list(range(head_n))
|
||||
used_indices.update(head_idx)
|
||||
sections.append(f"--- Primeras {head_n} filas ---\n{df.iloc[head_idx].to_csv(index=False)}")
|
||||
|
||||
if total_rows > n:
|
||||
tail_start = max(total_rows - n, head_n)
|
||||
tail_idx = list(range(tail_start, total_rows))
|
||||
new_tail = [i for i in tail_idx if i not in used_indices]
|
||||
if new_tail:
|
||||
used_indices.update(new_tail)
|
||||
sections.append(f"--- Últimas {len(new_tail)} filas ---\n{df.iloc[new_tail].to_csv(index=False)}")
|
||||
|
||||
if total_rows > 2 * n:
|
||||
mid_center = total_rows // 2
|
||||
mid_start = max(mid_center - n // 2, 0)
|
||||
mid_idx = list(range(mid_start, min(mid_start + n, total_rows)))
|
||||
new_mid = [i for i in mid_idx if i not in used_indices]
|
||||
if new_mid:
|
||||
used_indices.update(new_mid)
|
||||
sections.append(f"--- Filas del medio ---\n{df.iloc[new_mid].to_csv(index=False)}")
|
||||
|
||||
remaining = [i for i in range(total_rows) if i not in used_indices]
|
||||
if remaining:
|
||||
rand_n = min(n, len(remaining))
|
||||
rng = np.random.default_rng(42)
|
||||
rand_idx = sorted(rng.choice(remaining, size=rand_n, replace=False).tolist())
|
||||
used_indices.update(rand_idx)
|
||||
sections.append(f"--- {rand_n} filas aleatorias ---\n{df.iloc[rand_idx].to_csv(index=False)}")
|
||||
|
||||
sample_text = "\n\n".join(sections)
|
||||
return sample_text, col_info
|
||||
|
||||
|
||||
def _extract_python_code(text: str) -> str:
|
||||
"""Extrae el bloque de código Python de la respuesta del LLM."""
|
||||
pattern = r"```python\s*\n(.*?)```"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
pattern2 = r"```\s*\n(.*?)```"
|
||||
match2 = re.search(pattern2, text, re.DOTALL)
|
||||
if match2:
|
||||
return match2.group(1).strip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _call_responses_api(
|
||||
client: OpenAI,
|
||||
instructions: str,
|
||||
user_input: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Llama a la API de Responses (requerida por modelos codex)."""
|
||||
response = client.responses.create(
|
||||
model=POSTPROCESS_MODEL,
|
||||
instructions=instructions,
|
||||
input=user_input,
|
||||
)
|
||||
usage = None
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if raw_usage is not None:
|
||||
usage = {
|
||||
"input_tokens": getattr(raw_usage, "input_tokens", None),
|
||||
"output_tokens": getattr(raw_usage, "output_tokens", None),
|
||||
"total_tokens": getattr(raw_usage, "total_tokens", None),
|
||||
}
|
||||
return response.output_text, usage
|
||||
|
||||
|
||||
def generate_code(
|
||||
client: OpenAI,
|
||||
csv_sample: str,
|
||||
user_instructions: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Genera código Python invocando al LLM via Responses API.
|
||||
|
||||
Retorna (code_string, usage_dict).
|
||||
"""
|
||||
user_input = (
|
||||
f"MUESTRA DEL CSV:\n{csv_sample}\n\n"
|
||||
f"INSTRUCCIONES:\n{user_instructions}"
|
||||
)
|
||||
raw_text, usage = _call_responses_api(client, SYSTEM_PROMPT, user_input)
|
||||
code = _extract_python_code(raw_text)
|
||||
return code, usage
|
||||
|
||||
|
||||
def _build_safe_builtins() -> dict:
|
||||
import builtins as _b
|
||||
blocked = {"open", "exec", "eval", "__import__", "compile",
|
||||
"exit", "quit", "breakpoint", "input"}
|
||||
return {k: v for k, v in vars(_b).items() if k not in blocked}
|
||||
|
||||
_SAFE_BUILTINS = _build_safe_builtins()
|
||||
|
||||
|
||||
def execute_code(
|
||||
code: str,
|
||||
df: pd.DataFrame,
|
||||
timeout_s: float = 60,
|
||||
) -> Tuple[Optional[pd.DataFrame], Optional[str]]:
|
||||
"""Ejecuta código Python generado en un entorno restringido.
|
||||
|
||||
Retorna (df_result, error_message). Si error_message es None, la
|
||||
ejecución fue exitosa.
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
df_copy = df.copy()
|
||||
namespace: Dict[str, Any] = {
|
||||
"__builtins__": _SAFE_BUILTINS,
|
||||
"pd": pd,
|
||||
"np": np,
|
||||
"re": re,
|
||||
"datetime": _dt,
|
||||
"df": df_copy,
|
||||
}
|
||||
|
||||
result_holder: Dict[str, Any] = {"df": None, "error": None}
|
||||
|
||||
def _run():
|
||||
try:
|
||||
exec(code, namespace) # noqa: S102
|
||||
result_holder["df"] = namespace.get("df", df_copy)
|
||||
except Exception as exc:
|
||||
result_holder["error"] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=timeout_s)
|
||||
|
||||
if thread.is_alive():
|
||||
return None, "Timeout: la ejecución excedió el límite de tiempo."
|
||||
|
||||
if result_holder["error"]:
|
||||
return None, result_holder["error"]
|
||||
|
||||
result_df = result_holder["df"]
|
||||
if not isinstance(result_df, pd.DataFrame):
|
||||
return None, "El código no produjo un DataFrame válido en la variable `df`."
|
||||
|
||||
return result_df, None
|
||||
|
||||
|
||||
def retry_with_error(
|
||||
client: OpenAI,
|
||||
csv_sample: str,
|
||||
user_instructions: str,
|
||||
failed_code: str,
|
||||
error_msg: str,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
"""Re-envía al LLM el código fallido y el error para corrección."""
|
||||
retry_content = RETRY_TEMPLATE.format(
|
||||
code=failed_code,
|
||||
error=error_msg,
|
||||
instructions=user_instructions,
|
||||
sample=csv_sample,
|
||||
)
|
||||
raw_text, usage = _call_responses_api(client, SYSTEM_PROMPT, retry_content)
|
||||
code = _extract_python_code(raw_text)
|
||||
return code, usage
|
||||
|
||||
|
||||
def detect_delimiter(filepath: str) -> str:
|
||||
"""Detecta el delimitador de un archivo CSV leyendo las primeras líneas."""
|
||||
import csv as _csv
|
||||
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
sample = f.read(8192)
|
||||
|
||||
try:
|
||||
dialect = _csv.Sniffer().sniff(sample, delimiters=",;\t|")
|
||||
return dialect.delimiter
|
||||
except _csv.Error:
|
||||
return ","
|
||||
Reference in New Issue
Block a user