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:
erik
2026-03-01 21:40:49 -05:00
parent 4078c702dd
commit a1b0526080
8 changed files with 1087 additions and 264 deletions
+2
View File
@@ -1,6 +1,8 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,jupyternotebooks
# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,jupyternotebooks
*.db
### JupyterNotebooks ###
# gitignore template for Jupyter Notebooks
# website: http://jupyter.org/
+6 -1
View File
@@ -26,7 +26,12 @@ MODEL_PRICING = {
"input": 1.25,
"cached_input": 0.13,
"output": 10.00,
}
},
"gpt-5.1-codex-mini": {
"input": 0.25,
"cached_input": 0.025,
"output": 2.00,
},
}
# Legacy mapping o fase por defecto (si no se especifica modelo)
+254
View File
@@ -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 ","
+195 -4
View File
@@ -19,6 +19,8 @@ from application.strategies import OllamaExtractor
from application.database import DatabaseManager
from application.config import settings
from application.costs import MODEL_PRICING
from application import postprocess_agent
from openai import OpenAI
import pandas as pd
@@ -89,12 +91,31 @@ class AppController:
self.view.set_on_select_csv(self.on_select_csv)
self.view.set_on_save_settings(self.on_save_settings)
# Post-procesamiento
self._pp_df: pd.DataFrame | None = None
self._pp_result_df: pd.DataFrame | None = None
self._pp_csv_sample: str = ""
self._pp_client = OpenAI(api_key=settings.api.api_key, base_url=settings.api.base_url)
# Métricas acumuladas de post-procesamiento
self._pp_total_usd = 0.0
self._pp_total_calls = 0
self._pp_total_retries = 0
self._pp_total_input_tokens = 0
self._pp_total_output_tokens = 0
try:
self.view.set_on_postprocess_select_csv(self.on_pp_select_csv)
self.view.set_on_postprocess_run(self.on_pp_run)
self.view.set_on_postprocess_save(self.on_pp_save)
except Exception:
pass
# Mapeo de nombres de UI a IDs de modelo (y viceversa)
self.ui_to_model = {
"5.1": "gpt-5.1",
"4.1": "gpt-4.1",
"4.1 mini": "gpt-4.1-mini",
"4.1 nano": "gpt-4.1-nano"
"4.1 nano": "gpt-4.1-nano",
"5.1 Codex mini": "gpt-5.1-codex-mini",
}
self.model_to_ui = {v: k for k, v in self.ui_to_model.items()}
@@ -731,19 +752,23 @@ class AppController:
if bucket.get("extract") is not None and bucket.get("normalize") is not None:
self._completed_doc_count += 1
total_cost_doc = float(bucket["extract"] or 0.0) + float(bucket["normalize"] or 0.0)
# actualizar línea en hilo principal
self.view.after(0, lambda idx=self._completed_doc_count, val=total_cost_doc: self.view.update_cost_line(idx, val))
avg_cost = self._total_usd / max(self._completed_doc_count, 1)
self.view.after(0, lambda p=self._completed_doc_count, a=avg_cost: self.view.update_doc_kpi(p, a))
# Actualizar totales por fase
if phase in self._phase_tokens:
self._phase_tokens[phase]["input"] += int((usage or {}).get("prompt_tokens") or 0)
self._phase_tokens[phase]["cached"] += cached
self._phase_tokens[phase]["output"] += int((usage or {}).get("completion_tokens") or 0)
self._phase_tokens[phase]["usd"] += per_cost
# Actualizar barras por fase
ex = self._phase_tokens["extract"]
nz = self._phase_tokens["normalize"]
pp_in = self._pp_total_input_tokens
pp_out = self._pp_total_output_tokens
self.view.after(0, lambda: self.view.update_token_bars(
ex["input"], ex["cached"], ex["output"], nz["input"], nz["cached"], nz["output"]
ex["input"], ex["cached"], ex["output"],
nz["input"], nz["cached"], nz["output"],
pp_in, pp_out,
))
# Actualizar KPIs
overall = {
@@ -859,3 +884,169 @@ class AppController:
dlg.set_on_submit(on_submit)
if on_reprocess:
dlg.set_on_reprocess(on_reprocess)
# =========================================================================
# --- POST-PROCESAMIENTO ---
# =========================================================================
def on_pp_select_csv(self):
"""Abre un selector de archivo CSV y muestra la vista previa."""
filepath = fd.askopenfilename(
filetypes=[("CSV", "*.csv"), ("Todos", "*.*")],
title="Seleccionar CSV para post-procesamiento",
)
if not filepath:
return
try:
delimiter = postprocess_agent.detect_delimiter(filepath)
df = pd.read_csv(filepath, delimiter=delimiter, dtype=str)
except Exception as e:
mb.showerror("Error", f"No se pudo leer el CSV:\n{e}")
return
self._pp_df = df
self._pp_result_df = None
self.view.set_postprocess_csv_path(filepath)
self.view.enable_postprocess_save(False)
self.view.set_postprocess_code("")
self.view.set_postprocess_result("")
self.view.clear_postprocess_log()
sample_text, _ = postprocess_agent.sample_dataframe(df)
self._pp_csv_sample = sample_text
self.view.set_postprocess_preview(sample_text)
def on_pp_run(self):
"""Genera y ejecuta código de transformación con el agente LLM."""
if self._pp_df is None:
mb.showwarning("Aviso", "Primero seleccione un archivo CSV.")
return
instructions = self.view.get_postprocess_instructions()
if not instructions:
mb.showwarning("Aviso", "Escriba las instrucciones de transformación.")
return
self.view.enable_postprocess_controls(False)
self.view.enable_postprocess_save(False)
self.view.clear_postprocess_log()
self.view.set_postprocess_code("")
self.view.set_postprocess_result("")
self.view.set_postprocess_status("Procesando...")
csv_sample = self._pp_csv_sample
df = self._pp_df
def track_usage(usage, is_retry=False):
self._pp_total_calls += 1
if is_retry:
self._pp_total_retries += 1
if isinstance(usage, dict):
inp = int(usage.get("input_tokens") or 0)
out = int(usage.get("output_tokens") or 0)
self._pp_total_input_tokens += inp
self._pp_total_output_tokens += out
cost = (inp / 1_000_000.0) * 0.25 + (out / 1_000_000.0) * 2.00
self._pp_total_usd += cost
self.view.append_postprocess_log_safe(
f" Tokens: in={inp:,} out={out:,} | Costo: ${cost:.4f}"
)
def worker():
max_retries = 3
code = None
error_msg = None
try:
self.view.append_postprocess_log_safe("Generando código con el agente...")
code, usage = postprocess_agent.generate_code(
self._pp_client, csv_sample, instructions,
)
track_usage(usage)
self.view.after(0, lambda c=code: self.view.set_postprocess_code(c))
self.view.append_postprocess_log_safe("Código generado. Ejecutando...")
result_df, error_msg = postprocess_agent.execute_code(code, df)
attempt = 1
while error_msg and attempt < max_retries:
attempt += 1
self.view.append_postprocess_log_safe(
f"Error en ejecución: {error_msg}\nReintentando ({attempt}/{max_retries})..."
)
code, usage = postprocess_agent.retry_with_error(
self._pp_client, csv_sample, instructions, code, error_msg,
)
track_usage(usage, is_retry=True)
self.view.after(0, lambda c=code: self.view.set_postprocess_code(c))
self.view.append_postprocess_log_safe("Código corregido. Ejecutando...")
result_df, error_msg = postprocess_agent.execute_code(code, df)
# Actualizar dashboard de PP
short_instr = instructions[:60] + ("..." if len(instructions) > 60 else "")
self.view.after(0, lambda: self.view.update_pp_kpis(
self._pp_total_usd, self._pp_total_calls, self._pp_total_retries,
self._pp_total_input_tokens, self._pp_total_output_tokens,
short_instr,
))
self.view.after(0, lambda: self.view.append_usage_row(
"post-proc", short_instr,
{"prompt_tokens": self._pp_total_input_tokens, "completion_tokens": self._pp_total_output_tokens},
{"total_usd": self._pp_total_usd},
))
if error_msg:
self.view.append_postprocess_log_safe(
f"Error final tras {max_retries} intentos: {error_msg}"
)
self.view.after(0, lambda: self.view.set_postprocess_status("Error"))
final_err = error_msg
self.view.after(0, lambda: mb.showerror(
"Error", f"No se pudo aplicar la transformación tras {max_retries} intentos:\n{final_err}",
))
return
self._pp_result_df = result_df
result_sample, _ = postprocess_agent.sample_dataframe(result_df)
self.view.after(0, lambda s=result_sample: self.view.set_postprocess_result(s))
self.view.append_postprocess_log_safe(
f"Ejecución exitosa. Filas: {len(result_df):,} | Columnas: {len(result_df.columns)}"
)
self.view.after(0, lambda: self.view.enable_postprocess_save(True))
self.view.after(0, lambda: self.view.set_postprocess_status("Completado"))
except Exception as exc:
logger.exception("Error en post-procesamiento")
err_str = str(exc)
self.view.append_postprocess_log_safe(f"Error inesperado: {err_str}")
self.view.after(0, lambda: self.view.set_postprocess_status("Error"))
self.view.after(0, lambda m=err_str: mb.showerror("Error", f"Error inesperado:\n{m}"))
finally:
self.view.after(0, lambda: self.view.enable_postprocess_controls(True))
threading.Thread(target=worker, daemon=True).start()
def on_pp_save(self):
"""Guarda el DataFrame resultante del post-procesamiento."""
if self._pp_result_df is None:
mb.showwarning("Aviso", "No hay resultado para guardar.")
return
path = fd.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV", "*.csv"), ("Excel", "*.xlsx"), ("Todos", "*.*")],
title="Guardar CSV post-procesado",
)
if not path:
return
try:
if path.endswith(".xlsx"):
self._pp_result_df.to_excel(path, index=False)
else:
self._pp_result_df.to_csv(path, index=False)
mb.showinfo("Guardado", f"Archivo guardado correctamente:\n{path}")
except Exception as e:
logger.exception("Error guardando CSV post-procesado")
mb.showerror("Error", f"No se pudo guardar el archivo:\n{e}")
+2 -1
View File
@@ -4,5 +4,6 @@ from gui.tabs.results_tab import ResultsTab
from gui.tabs.main_tab import MainProcessingTab
from gui.tabs.preclass_tab import PreclassificationTab
from gui.tabs.metrics_tab import MetricsTab
from gui.tabs.postprocess_tab import PostProcessTab
__all__ = ["SettingsTab", "ResultsTab", "MainProcessingTab", "PreclassificationTab", "MetricsTab"]
__all__ = ["SettingsTab", "ResultsTab", "MainProcessingTab", "PreclassificationTab", "MetricsTab", "PostProcessTab"]
+336 -253
View File
@@ -1,12 +1,15 @@
"""Pestaña Dashboard & Métricas de la aplicación GUI."""
"""Pestaña Dashboard & Métricas de la aplicación GUI.
Muestra KPIs, gráficos y detalle de costos para Extracción, Normalización
y Post-Procesamiento.
"""
import customtkinter as ctk
import tkinter.ttk as ttk
from gui.styles import AppStyles
# matplotlib y numpy son opcionales
try:
import matplotlib
matplotlib.use("Agg") # backend no interactivo para render en canvas Tk
matplotlib.use("Agg")
import numpy as np
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
@@ -16,253 +19,333 @@ except Exception:
class MetricsTab:
"""Encapsula la UI de la pestaña Dashboard & Métricas."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Métricas.
Args:
parent_tab: El CTkFrame que representa la pestaña.
"""
self.tab = parent_tab
self.fonts = AppStyles.get_fonts()
self.colors = AppStyles.get_colors()
self.spacing = AppStyles.get_spacing()
# Variables internas para gráficos
self._cost_line_points_x = []
self._cost_line_points_y = []
self._setup_layout()
self._create_kpi_section()
self._create_charts_section()
self._create_comparison_section()
self._create_usage_section()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(1, weight=1)
self.tab.grid_rowconfigure(3, weight=1)
def _create_kpi_section(self):
"""Crea la sección de KPIs."""
self.kpi_frame = ctk.CTkFrame(self.tab)
self.kpi_frame.grid(row=0, column=0, sticky="ew", padx=15, pady=15)
self.kpi_frame.grid_columnconfigure((0, 1, 2), weight=1)
# Helper para crear cards KPI
def create_kpi_card(parent, title, row, col):
card = ctk.CTkFrame(parent)
card.grid(row=row, column=col, sticky="nsew", padx=5, pady=5)
lbl_title = ctk.CTkLabel(
card,
text=title,
font=ctk.CTkFont(size=14, weight="bold")
)
lbl_title.pack(pady=(10, 5), padx=10, anchor="w")
lbl_content = ctk.CTkLabel(
card,
text="...",
font=self.fonts["mono"],
justify="left"
)
lbl_content.pack(pady=(0, 10), padx=10, anchor="w")
return lbl_content
self.kpi_overall = create_kpi_card(self.kpi_frame, "Total General", 0, 0)
self.kpi_extract = create_kpi_card(self.kpi_frame, "Fase: Extracción", 0, 1)
self.kpi_normalize = create_kpi_card(self.kpi_frame, "Fase: Normalización", 0, 2)
self.kpi_overall.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
self.kpi_extract.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
self.kpi_normalize.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
def _create_charts_section(self):
"""Crea la sección de gráficos."""
self.charts_frame = ctk.CTkFrame(self.tab)
self.charts_frame.grid(row=1, column=0, sticky="nsew", padx=15, pady=5)
self.charts_frame.grid_columnconfigure((0, 1), weight=1)
self.charts_frame.grid_rowconfigure(0, weight=1)
# Gráfico Línea
if HAS_MPL:
self.fig_line = Figure(figsize=(4, 3), dpi=100)
self.fig_line.patch.set_facecolor('#f0f0f0')
self.ax_line = self.fig_line.add_subplot(111)
self.ax_line.set_title("Costo Acumulado (USD)", fontsize=10)
self.ax_line.tick_params(labelsize=8)
self._line_plot, = self.ax_line.plot([], [], marker="o", color="#1f77b4")
self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=self.charts_frame)
self.canvas_line.get_tk_widget().grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
else:
ctk.CTkLabel(self.charts_frame, text="Gráficos no disponibles").grid(row=0, column=0)
# Gráfico Barras
if HAS_MPL:
self.fig_bars = Figure(figsize=(4, 3), dpi=100)
self.fig_bars.patch.set_facecolor('#f0f0f0')
self.ax_bars = self.fig_bars.add_subplot(111)
self.ax_bars.set_title("Consumo de Tokens", fontsize=10)
self.ax_bars.tick_params(labelsize=8)
self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=self.charts_frame)
self.canvas_bars.get_tk_widget().grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
def _create_comparison_section(self):
"""Crea la sección de comparación de tarifas."""
self.comparison_frame = ctk.CTkFrame(self.tab)
self.comparison_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=5)
self.comparison_frame.grid_columnconfigure(0, weight=1)
lbl_comp_title = ctk.CTkLabel(
self.comparison_frame,
text="Tarifas de Modelos (USD por 1M tokens)",
font=ctk.CTkFont(size=14, weight="bold")
)
lbl_comp_title.grid(row=0, column=0, sticky="w", padx=10, pady=5)
# Tabla de comparación
self.tree_comparison = ttk.Treeview(
self.comparison_frame,
columns=("model", "input", "cached", "output"),
show="headings",
height=4
)
self.tree_comparison.heading("model", text="Modelo")
self.tree_comparison.heading("input", text="Input / 1M")
self.tree_comparison.heading("cached", text="Cached / 1M")
self.tree_comparison.heading("output", text="Output / 1M")
self.tree_comparison.column("model", width=150, anchor="w")
self.tree_comparison.column("input", width=100, anchor="e")
self.tree_comparison.column("cached", width=100, anchor="e")
self.tree_comparison.column("output", width=100, anchor="e")
self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
def _create_usage_section(self):
"""Crea la sección de detalle de uso."""
self.usage_text = ctk.CTkTextbox(self.tab, height=150, font=self.fonts["mono"])
self.usage_text.grid(row=3, column=0, sticky="ew", padx=15, pady=15)
# --- API Pública ---
def append_usage(self, text: str) -> None:
"""Añade texto al área de uso."""
self.usage_text.insert("end", text + "\n")
self.usage_text.see("end")
def append_usage_safe(self, text: str) -> None:
"""Añade texto al área de uso de forma thread-safe."""
self.append_usage(text)
def clear(self) -> None:
"""Limpia todos los datos de la pestaña."""
self.usage_text.delete("1.0", "end")
try:
for item in self.tree_comparison.get_children():
self.tree_comparison.delete(item)
except Exception:
pass
# Reset KPIs
zero_txt = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
self.kpi_overall.configure(text=zero_txt)
self.kpi_extract.configure(text=zero_txt)
self.kpi_normalize.configure(text=zero_txt)
# Reset Charts
if HAS_MPL:
self._cost_line_points_x.clear()
self._cost_line_points_y.clear()
self._line_plot.set_data([], [])
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
self.ax_bars.clear()
self.ax_bars.set_title("Consumo de Tokens")
self.canvas_bars.draw_idle()
def set_usage_totals(self, **kwargs):
"""Legacy method, handled by KPIs now."""
pass
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
"""Actualiza los KPIs con los datos proporcionados."""
def fmt(d):
return (
f"input: {d.get('input',0)}\n"
f"cached: {d.get('cached',0)}\n"
f"output: {d.get('output',0)}\n"
f"USD: ${d.get('usd',0.0):.4f}"
)
self.kpi_overall.configure(text=fmt(overall))
self.kpi_extract.configure(text=fmt(extract_phase))
self.kpi_normalize.configure(text=fmt(normalize_phase))
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
"""Actualiza el gráfico de línea de costo acumulado."""
if not HAS_MPL:
return
try:
self._cost_line_points_x.append(doc_index)
self._cost_line_points_y.append(total_cost_usd)
self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y)
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
except Exception:
pass
def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int,
input_norm: int, cached_norm: int, output_norm: int) -> None:
"""Actualiza el gráfico de barras de tokens."""
if not HAS_MPL:
return
try:
self.ax_bars.clear()
self.ax_bars.set_title("Tokens por Fase")
labels = ["Input", "Cached", "Output"]
extract_vals = [input_extract, cached_extract, output_extract]
norm_vals = [input_norm, cached_norm, output_norm]
x = np.arange(len(labels))
width = 0.35
self.ax_bars.bar(x - width/2, extract_vals, width, label="Extracción", color="#1f77b4")
self.ax_bars.bar(x + width/2, norm_vals, width, label="Normalización", color="#ff7f0e")
self.ax_bars.set_xticks(x, labels)
self.ax_bars.legend()
self.canvas_bars.draw_idle()
except Exception:
pass
def update_comparison_table(self, comparisons: list[dict]) -> None:
"""Actualiza la tabla de comparación de tarifas."""
try:
# Limpiar tabla
for item in self.tree_comparison.get_children():
self.tree_comparison.delete(item)
# Insertar filas
for c in comparisons:
self.tree_comparison.insert("", "end", values=(
c.get("model", "N/D"),
f"${c.get('input', 0.0):.2f}",
f"${c.get('cached_input', 0.0):.2f}",
f"${c.get('output', 0.0):.2f}"
))
except Exception:
pass
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
"""Añade una fila de detalle de uso."""
try:
usd = (cost or {}).get('total_usd', 0.0)
self.append_usage(f"[{phase:<10}] {radicado:<20} | cost=${usd:.4f} | usage={usage}")
except Exception:
pass
"""Encapsula la UI de la pestaña Dashboard & Métricas."""
def __init__(self, parent_tab):
self.tab = parent_tab
self.fonts = AppStyles.get_fonts()
self.colors = AppStyles.get_colors()
self.spacing = AppStyles.get_spacing()
self._cost_line_points_x: list[int] = []
self._cost_line_points_y: list[float] = []
self._pp_history: list[dict] = []
self._setup_layout()
self._create_kpi_section()
self._create_pp_kpi_section()
self._create_charts_section()
self._create_comparison_section()
self._create_detail_section()
# -- Layout ----------------------------------------------------------------
def _setup_layout(self):
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=0)
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.scroll.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.scroll.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
# -- KPI cards helper ------------------------------------------------------
@staticmethod
def _make_kpi_card(parent, title, row, col, accent=None):
card = ctk.CTkFrame(parent)
card.grid(row=row, column=col, sticky="nsew", padx=4, pady=4)
card.grid_columnconfigure(0, weight=1)
header = ctk.CTkFrame(card, height=6, fg_color=accent or ("#1f77b4", "#1f77b4"), corner_radius=0)
header.grid(row=0, column=0, sticky="ew")
header.grid_propagate(False)
lbl_title = ctk.CTkLabel(card, text=title, font=ctk.CTkFont(size=12, weight="bold"))
lbl_title.grid(row=1, column=0, sticky="w", padx=12, pady=(8, 2))
lbl_value = ctk.CTkLabel(card, text="—", font=ctk.CTkFont(family="Consolas", size=11), justify="left")
lbl_value.grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
return lbl_value
# -- Extracción KPIs -------------------------------------------------------
def _create_kpi_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=0, column=0, sticky="ew", padx=2, pady=(2, 1))
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
lbl = ctk.CTkLabel(frame, text="Extracción de Documentos", font=ctk.CTkFont(size=15, weight="bold"))
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
self.kpi_overall = self._make_kpi_card(frame, "Total Sesión", 1, 0, accent=("#2E86C1", "#2E86C1"))
self.kpi_extract = self._make_kpi_card(frame, "Fase: Extracción", 1, 1, accent=("#1f77b4", "#1f77b4"))
self.kpi_normalize = self._make_kpi_card(frame, "Fase: Normalización", 1, 2, accent=("#ff7f0e", "#ff7f0e"))
self.kpi_docs = self._make_kpi_card(frame, "Documentos", 1, 3, accent=("#27AE60", "#27AE60"))
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
self.kpi_overall.configure(text=zero)
self.kpi_extract.configure(text=zero)
self.kpi_normalize.configure(text=zero)
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
# -- Post-Procesamiento KPIs ----------------------------------------------
def _create_pp_kpi_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=1, column=0, sticky="ew", padx=2, pady=1)
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
lbl = ctk.CTkLabel(frame, text="Post-Procesamiento (Agente Codex)", font=ctk.CTkFont(size=15, weight="bold"))
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
self.kpi_pp_total = self._make_kpi_card(frame, "Total Acumulado", 1, 0, accent=("#8E44AD", "#8E44AD"))
self.kpi_pp_calls = self._make_kpi_card(frame, "Llamadas API", 1, 1, accent=("#9B59B6", "#9B59B6"))
self.kpi_pp_tokens = self._make_kpi_card(frame, "Tokens", 1, 2, accent=("#AF7AC5", "#AF7AC5"))
self.kpi_pp_last = self._make_kpi_card(frame, "Última ejecución", 1, 3, accent=("#D2B4DE", "#D2B4DE"))
self.kpi_pp_total.configure(text="USD: $0.0000")
self.kpi_pp_calls.configure(text="Total: 0\nReintentos: 0")
self.kpi_pp_tokens.configure(text="input: 0\noutput: 0")
self.kpi_pp_last.configure(text="—")
# -- Charts ----------------------------------------------------------------
def _create_charts_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=2, column=0, sticky="ew", padx=2, pady=1)
frame.grid_columnconfigure((0, 1), weight=1)
lbl = ctk.CTkLabel(frame, text="Gráficos", font=ctk.CTkFont(size=15, weight="bold"))
lbl.grid(row=0, column=0, columnspan=2, sticky="w", padx=12, pady=(10, 4))
if HAS_MPL:
bg = "#2b2b2b"
self.fig_line = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
self.ax_line = self.fig_line.add_subplot(111)
self._style_axis(self.ax_line, "Costo Acumulado por Documento (USD)")
self._line_plot, = self.ax_line.plot([], [], marker="o", markersize=4, linewidth=1.5, color="#2ecc71")
self.fig_line.tight_layout(pad=1.5)
self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=frame)
self.canvas_line.get_tk_widget().grid(row=1, column=0, padx=8, pady=(0, 8), sticky="nsew")
self.fig_bars = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
self.ax_bars = self.fig_bars.add_subplot(111)
self._style_axis(self.ax_bars, "Tokens por Fase")
self.fig_bars.tight_layout(pad=1.5)
self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=frame)
self.canvas_bars.get_tk_widget().grid(row=1, column=1, padx=8, pady=(0, 8), sticky="nsew")
else:
ctk.CTkLabel(frame, text="matplotlib no disponible — instálelo para gráficos").grid(
row=1, column=0, columnspan=2, pady=10,
)
@staticmethod
def _style_axis(ax, title):
bg = "#2b2b2b"
fg = "#cccccc"
ax.set_facecolor(bg)
ax.set_title(title, fontsize=10, color=fg, pad=8)
ax.tick_params(colors=fg, labelsize=8)
for spine in ax.spines.values():
spine.set_color("#555555")
ax.xaxis.label.set_color(fg)
ax.yaxis.label.set_color(fg)
# -- Comparison table ------------------------------------------------------
def _create_comparison_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=3, column=0, sticky="ew", padx=2, pady=1)
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(frame, text="Tarifas de Modelos (USD / 1M tokens)", font=ctk.CTkFont(size=15, weight="bold"))
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
style = ttk.Style()
style.configure("Metrics.Treeview", rowheight=26, font=("Consolas", 10))
style.configure("Metrics.Treeview.Heading", font=("Roboto", 11, "bold"))
self.tree_comparison = ttk.Treeview(
frame,
columns=("model", "input", "cached", "output"),
show="headings", height=6, style="Metrics.Treeview",
)
self.tree_comparison.heading("model", text="Modelo")
self.tree_comparison.heading("input", text="Input")
self.tree_comparison.heading("cached", text="Cached Input")
self.tree_comparison.heading("output", text="Output")
self.tree_comparison.column("model", width=180, anchor="w")
self.tree_comparison.column("input", width=110, anchor="e")
self.tree_comparison.column("cached", width=110, anchor="e")
self.tree_comparison.column("output", width=110, anchor="e")
self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
# -- Detail log ------------------------------------------------------------
def _create_detail_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=4, column=0, sticky="ew", padx=2, pady=(1, 2))
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(frame, text="Detalle de uso (log)", font=ctk.CTkFont(size=15, weight="bold"))
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
self.usage_text = ctk.CTkTextbox(frame, height=160, font=self.fonts["mono_small"], wrap="none")
self.usage_text.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
# =========================================================================
# API PÚBLICA
# =========================================================================
# -- Extracción KPIs -------------------------------------------------------
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
def fmt(d):
return (
f"input: {d.get('input', 0):,}\n"
f"cached: {d.get('cached', 0):,}\n"
f"output: {d.get('output', 0):,}\n"
f"USD: ${d.get('usd', 0.0):.4f}"
)
self.kpi_overall.configure(text=fmt(overall))
self.kpi_extract.configure(text=fmt(extract_phase))
self.kpi_normalize.configure(text=fmt(normalize_phase))
def update_doc_kpi(self, processed: int, avg_cost: float) -> None:
self.kpi_docs.configure(text=f"Procesados: {processed}\nCosto prom: ${avg_cost:.4f}")
# -- Post-Procesamiento KPIs ----------------------------------------------
def update_pp_kpis(
self,
total_usd: float,
total_calls: int,
total_retries: int,
input_tokens: int,
output_tokens: int,
last_description: str,
) -> None:
self.kpi_pp_total.configure(text=f"USD: ${total_usd:.4f}")
self.kpi_pp_calls.configure(text=f"Total: {total_calls}\nReintentos: {total_retries}")
self.kpi_pp_tokens.configure(text=f"input: {input_tokens:,}\noutput: {output_tokens:,}")
self.kpi_pp_last.configure(text=last_description or "—")
# -- Charts ----------------------------------------------------------------
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
if not HAS_MPL:
return
try:
self._cost_line_points_x.append(doc_index)
self._cost_line_points_y.append(total_cost_usd)
self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y)
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
except Exception:
pass
def update_token_bars(
self,
input_extract: int, cached_extract: int, output_extract: int,
input_norm: int, cached_norm: int, output_norm: int,
input_pp: int = 0, output_pp: int = 0,
) -> None:
if not HAS_MPL:
return
try:
self.ax_bars.clear()
self._style_axis(self.ax_bars, "Tokens por Fase")
labels = ["Input", "Cached", "Output"]
extract_vals = [input_extract, cached_extract, output_extract]
norm_vals = [input_norm, cached_norm, output_norm]
pp_vals = [input_pp, 0, output_pp]
has_pp = input_pp > 0 or output_pp > 0
x = np.arange(len(labels))
width = 0.25 if has_pp else 0.35
offset = width
self.ax_bars.bar(x - offset, extract_vals, width, label="Extracción", color="#3498db")
self.ax_bars.bar(x, norm_vals, width, label="Normalización", color="#e67e22")
if has_pp:
self.ax_bars.bar(x + offset, pp_vals, width, label="Post-Proc.", color="#9b59b6")
self.ax_bars.set_xticks(x, labels)
leg = self.ax_bars.legend(fontsize=8, framealpha=0.6)
for text in leg.get_texts():
text.set_color("#cccccc")
self.fig_bars.tight_layout(pad=1.5)
self.canvas_bars.draw_idle()
except Exception:
pass
# -- Comparison table ------------------------------------------------------
def update_comparison_table(self, comparisons: list[dict]) -> None:
try:
for item in self.tree_comparison.get_children():
self.tree_comparison.delete(item)
for c in comparisons:
self.tree_comparison.insert("", "end", values=(
c.get("model", "N/D"),
f"${c.get('input', 0.0):.3f}",
f"${c.get('cached_input', 0.0):.4f}",
f"${c.get('output', 0.0):.2f}",
))
except Exception:
pass
# -- Detail log ------------------------------------------------------------
def append_usage(self, text: str) -> None:
self.usage_text.insert("end", text + "\n")
self.usage_text.see("end")
def append_usage_safe(self, text: str) -> None:
self.append_usage(text)
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
try:
usd = (cost or {}).get("total_usd", 0.0)
prompt = int((usage or {}).get("prompt_tokens", 0) or 0)
comp = int((usage or {}).get("completion_tokens", 0) or 0)
self.append_usage(
f"[{phase:<12}] {radicado:<22} | in={prompt:>7,} out={comp:>6,} | ${usd:.4f}"
)
except Exception:
pass
def clear(self) -> None:
self.usage_text.delete("1.0", "end")
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
self.kpi_overall.configure(text=zero)
self.kpi_extract.configure(text=zero)
self.kpi_normalize.configure(text=zero)
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
if HAS_MPL:
self._cost_line_points_x.clear()
self._cost_line_points_y.clear()
self._line_plot.set_data([], [])
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
self.ax_bars.clear()
self._style_axis(self.ax_bars, "Tokens por Fase")
self.canvas_bars.draw_idle()
def set_usage_totals(self, **kwargs):
pass
+227
View File
@@ -0,0 +1,227 @@
"""Pestaña de Post-Procesamiento con agente inteligente."""
import customtkinter as ctk
from gui.styles import AppStyles
class PostProcessTab:
"""UI de la pestaña de Post-Procesamiento de CSV."""
def __init__(self, parent_tab):
self.tab = parent_tab
self.fonts = AppStyles.get_fonts()
self.colors = AppStyles.get_colors()
self.spacing = AppStyles.get_spacing()
self._setup_layout()
self._create_file_section()
self._create_preview_section()
self._create_instructions_section()
self._create_code_section()
self._create_log_section()
self._create_result_section()
# -- Layout ------------------------------------------------------------------
def _setup_layout(self):
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.scroll.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
self.scroll.grid_columnconfigure(0, weight=1)
# -- Sección archivo ---------------------------------------------------------
def _create_file_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 2))
frame.grid_columnconfigure(1, weight=1)
lbl_title = ctk.CTkLabel(frame, text="Archivo CSV", font=self.fonts["section"])
lbl_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5))
self.btn_select_csv = ctk.CTkButton(
frame, text="Seleccionar CSV", font=self.fonts["ui"], width=160,
)
self.btn_select_csv.grid(row=1, column=0, padx=15, pady=8, sticky="w")
self.lbl_csv_path = ctk.CTkLabel(
frame, text="Ningún archivo seleccionado",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
)
self.lbl_csv_path.grid(row=1, column=1, padx=10, pady=8, sticky="w")
# -- Preview datos -----------------------------------------------------------
def _create_preview_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=1, column=0, sticky="ew", padx=5, pady=2)
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(frame, text="Vista previa (muestra)", font=self.fonts["section"])
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
self.txt_preview = ctk.CTkTextbox(
frame, height=200, font=self.fonts["mono_small"],
state="disabled", wrap="none",
)
self.txt_preview.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
# -- Instrucciones -----------------------------------------------------------
def _create_instructions_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=2, column=0, sticky="ew", padx=5, pady=2)
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(
frame, text="Instrucciones de transformación", font=self.fonts["section"],
)
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 2))
hint = ctk.CTkLabel(
frame,
text="Describe qué cambios quieres aplicar y en qué columnas. Ejemplo: "
"\"En la columna 'monto', multiplicar por 1.19 para agregar IVA\"",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
wraplength=900, justify="left",
)
hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5))
self.txt_instructions = ctk.CTkTextbox(
frame, height=90, font=self.fonts["ui"], wrap="word",
)
self.txt_instructions.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 5))
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.grid(row=3, column=0, sticky="ew", padx=15, pady=(0, 10))
self.btn_run = ctk.CTkButton(
btn_frame, text="Generar y Aplicar", font=self.fonts["button_large"],
fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"],
height=38,
)
self.btn_run.pack(side="left")
self.lbl_status = ctk.CTkLabel(
btn_frame, text="", font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
)
self.lbl_status.pack(side="left", padx=15)
# -- Código generado ---------------------------------------------------------
def _create_code_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=3, column=0, sticky="ew", padx=5, pady=2)
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(frame, text="Código generado", font=self.fonts["section"])
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
self.txt_code = ctk.CTkTextbox(
frame, height=180, font=self.fonts["mono_small"],
state="disabled", wrap="none",
)
self.txt_code.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
# -- Log de ejecución --------------------------------------------------------
def _create_log_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=4, column=0, sticky="ew", padx=5, pady=2)
frame.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(frame, text="Log de ejecución", font=self.fonts["section"])
lbl.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 5))
self.txt_log = ctk.CTkTextbox(
frame, height=100, font=self.fonts["mono_small"],
state="disabled", wrap="word",
)
self.txt_log.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
# -- Resultado ---------------------------------------------------------------
def _create_result_section(self):
frame = ctk.CTkFrame(self.scroll)
frame.grid(row=5, column=0, sticky="ew", padx=5, pady=(2, 5))
frame.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(frame, fg_color="transparent")
top.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 5))
top.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(top, text="Vista previa del resultado", font=self.fonts["section"])
lbl.pack(side="left")
self.btn_save = ctk.CTkButton(
top, text="Guardar CSV", font=self.fonts["ui"], width=140,
fg_color=self.colors["success"], hover_color=self.colors["success_hover"],
state="disabled",
)
self.btn_save.pack(side="right")
self.txt_result = ctk.CTkTextbox(
frame, height=200, font=self.fonts["mono_small"],
state="disabled", wrap="none",
)
self.txt_result.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
# -- API pública -------------------------------------------------------------
def set_on_select_csv(self, callback):
self.btn_select_csv.configure(command=callback)
def set_on_run(self, callback):
self.btn_run.configure(command=callback)
def set_on_save(self, callback):
self.btn_save.configure(command=callback)
def set_csv_path(self, path: str):
self.lbl_csv_path.configure(text=path or "Ningún archivo seleccionado")
def get_instructions(self) -> str:
return self.txt_instructions.get("1.0", "end").strip()
def set_status(self, text: str):
self.lbl_status.configure(text=text)
def set_preview(self, text: str):
self.txt_preview.configure(state="normal")
self.txt_preview.delete("1.0", "end")
self.txt_preview.insert("1.0", text)
self.txt_preview.configure(state="disabled")
def set_code(self, text: str):
self.txt_code.configure(state="normal")
self.txt_code.delete("1.0", "end")
self.txt_code.insert("1.0", text)
self.txt_code.configure(state="disabled")
def append_log(self, text: str):
self.txt_log.configure(state="normal")
self.txt_log.insert("end", text + "\n")
self.txt_log.see("end")
self.txt_log.configure(state="disabled")
def clear_log(self):
self.txt_log.configure(state="normal")
self.txt_log.delete("1.0", "end")
self.txt_log.configure(state="disabled")
def set_result(self, text: str):
self.txt_result.configure(state="normal")
self.txt_result.delete("1.0", "end")
self.txt_result.insert("1.0", text)
self.txt_result.configure(state="disabled")
def enable_save(self, enabled: bool):
self.btn_save.configure(state="normal" if enabled else "disabled")
def enable_controls(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.btn_select_csv.configure(state=state)
self.btn_run.configure(state=state)
self.txt_instructions.configure(state=state)
+65 -5
View File
@@ -6,7 +6,7 @@ Expone métodos para enlazar callbacks, leer entradas y escribir salida en un
import os
import customtkinter as ctk
from gui.styles import AppStyles
from gui.tabs import SettingsTab, ResultsTab, MainProcessingTab, PreclassificationTab, MetricsTab
from gui.tabs import SettingsTab, ResultsTab, MainProcessingTab, PreclassificationTab, MetricsTab, PostProcessTab
class InvoiceAppView(ctk.CTk):
@@ -43,6 +43,7 @@ class InvoiceAppView(ctk.CTk):
self.tab_results = self.tabs.add("Resultados")
self.tab_settings = self.tabs.add("Configuración")
self.tab_usage = self.tabs.add("Dashboard & Métricas")
self.tab_postprocess = self.tabs.add("Post-Procesamiento")
# =========================================================================
# --- TAB: PROCESAR DOCUMENTOS ---
@@ -71,6 +72,11 @@ class InvoiceAppView(ctk.CTk):
# =========================================================================
self.metrics_tab = MetricsTab(self.tab_usage)
# =========================================================================
# --- TAB: POST-PROCESAMIENTO ---
# =========================================================================
self.postprocess_tab = PostProcessTab(self.tab_postprocess)
# ---------------------------------------------------------------------------
# MÉTODOS PÚBLICOS (API COMPATIBLE)
@@ -241,13 +247,18 @@ class InvoiceAppView(ctk.CTk):
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
self.metrics_tab.update_kpis(overall, extract_phase, normalize_phase)
def update_doc_kpi(self, processed: int, avg_cost: float) -> None:
self.metrics_tab.update_doc_kpi(processed, avg_cost)
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
self.metrics_tab.update_cost_line(doc_index, total_cost_usd)
def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int,
input_norm: int, cached_norm: int, output_norm: int) -> None:
self.metrics_tab.update_token_bars(input_extract, cached_extract, output_extract,
input_norm, cached_norm, output_norm)
def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int,
input_norm: int, cached_norm: int, output_norm: int,
input_pp: int = 0, output_pp: int = 0) -> None:
self.metrics_tab.update_token_bars(input_extract, cached_extract, output_extract,
input_norm, cached_norm, output_norm,
input_pp, output_pp)
def update_comparison_table(self, comparisons: list[dict]) -> None:
self.metrics_tab.update_comparison_table(comparisons)
@@ -255,12 +266,61 @@ class InvoiceAppView(ctk.CTk):
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
self.metrics_tab.append_usage_row(phase, radicado, usage, cost)
def update_pp_kpis(self, total_usd: float, total_calls: int, total_retries: int,
input_tokens: int, output_tokens: int, last_description: str) -> None:
self.metrics_tab.update_pp_kpis(total_usd, total_calls, total_retries,
input_tokens, output_tokens, last_description)
# --- HELPERS INTERNOS ---
def get_preclass_patterns(self) -> list[dict]:
"""Devuelve la lista de patrones de preclasificación definida en la pestaña."""
return self.preclass_tab.get_patterns()
# --- TAB POST-PROCESAMIENTO ---
def set_on_postprocess_select_csv(self, callback):
self.postprocess_tab.set_on_select_csv(callback)
def set_on_postprocess_run(self, callback):
self.postprocess_tab.set_on_run(callback)
def set_on_postprocess_save(self, callback):
self.postprocess_tab.set_on_save(callback)
def get_postprocess_instructions(self) -> str:
return self.postprocess_tab.get_instructions()
def set_postprocess_csv_path(self, path: str):
self.postprocess_tab.set_csv_path(path)
def set_postprocess_preview(self, text: str):
self.postprocess_tab.set_preview(text)
def set_postprocess_code(self, text: str):
self.postprocess_tab.set_code(text)
def append_postprocess_log(self, text: str):
self.postprocess_tab.append_log(text)
def append_postprocess_log_safe(self, text: str):
self.after(0, lambda: self.postprocess_tab.append_log(text))
def clear_postprocess_log(self):
self.postprocess_tab.clear_log()
def set_postprocess_result(self, text: str):
self.postprocess_tab.set_result(text)
def set_postprocess_status(self, text: str):
self.postprocess_tab.set_status(text)
def enable_postprocess_save(self, enabled: bool):
self.postprocess_tab.enable_save(enabled)
def enable_postprocess_controls(self, enabled: bool):
self.postprocess_tab.enable_controls(enabled)
def get_doc_types(self) -> list[dict]:
return self.main_tab.get_doc_types()