Files
IPSExtractor/application/postprocess_agent.py
T

255 lines
7.5 KiB
Python

"""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 ","