From a1b0526080c95260e5911ecfdec9b40f67178e85 Mon Sep 17 00:00:00 2001 From: erik Date: Sun, 1 Mar 2026 21:40:49 -0500 Subject: [PATCH] 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. --- .gitignore | 2 + application/costs.py | 7 +- application/postprocess_agent.py | 254 +++++++++++++ gui/controller.py | 199 ++++++++++- gui/tabs/__init__.py | 3 +- gui/tabs/metrics_tab.py | 589 ++++++++++++++++++------------- gui/tabs/postprocess_tab.py | 227 ++++++++++++ gui/view.py | 70 +++- 8 files changed, 1087 insertions(+), 264 deletions(-) create mode 100644 application/postprocess_agent.py create mode 100644 gui/tabs/postprocess_tab.py diff --git a/.gitignore b/.gitignore index 6221c05..5b058fb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/application/costs.py b/application/costs.py index 82205b9..ebfb298 100644 --- a/application/costs.py +++ b/application/costs.py @@ -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) diff --git a/application/postprocess_agent.py b/application/postprocess_agent.py new file mode 100644 index 0000000..754540e --- /dev/null +++ b/application/postprocess_agent.py @@ -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 "," diff --git a/gui/controller.py b/gui/controller.py index 92a96ec..b1392b7 100644 --- a/gui/controller.py +++ b/gui/controller.py @@ -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}") diff --git a/gui/tabs/__init__.py b/gui/tabs/__init__.py index e819434..535d8bc 100644 --- a/gui/tabs/__init__.py +++ b/gui/tabs/__init__.py @@ -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"] diff --git a/gui/tabs/metrics_tab.py b/gui/tabs/metrics_tab.py index 09dde31..760dc6a 100644 --- a/gui/tabs/metrics_tab.py +++ b/gui/tabs/metrics_tab.py @@ -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 diff --git a/gui/tabs/postprocess_tab.py b/gui/tabs/postprocess_tab.py new file mode 100644 index 0000000..02adf52 --- /dev/null +++ b/gui/tabs/postprocess_tab.py @@ -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) diff --git a/gui/view.py b/gui/view.py index 7bb4f4c..768ba2b 100644 --- a/gui/view.py +++ b/gui/view.py @@ -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()