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:
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user