feat: Enhance GUI and processing services with new batch processing and estimation features, update concurrency settings, and improve styling organization.

This commit is contained in:
erik
2026-03-01 19:56:10 -05:00
parent 69debb1c93
commit 3c258ebf12
14 changed files with 1777 additions and 1071 deletions
+5 -2
View File
@@ -6,7 +6,10 @@ import os
import logging
import asyncio
import openai
from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable
from typing import List, Dict, Any, Optional, Tuple, Callable, Awaitable, TYPE_CHECKING
if TYPE_CHECKING:
from application.service import InvoiceProcessingService
from application.profiles import get_profile
from application.prompts import PromptBuilder
@@ -21,7 +24,7 @@ from core.csv_utils import rows_from_data
logger = logging.getLogger(__name__)
class BatchProcessor:
def __init__(self, service):
def __init__(self, service: 'InvoiceProcessingService'):
"""
Args:
service: Instancia de InvoiceProcessingService para delegar la extracción individual.
+1 -1
View File
@@ -50,7 +50,7 @@ class RetrySettings(BaseSettings):
class ProcessingSettings(BaseSettings):
"""Configuración de procesamiento y concurrencia."""
concurrency_limit: int = Field(20, validation_alias=AliasChoices("CONCURRENCY_LIMIT"))
concurrency_limit: int = Field(100, validation_alias=AliasChoices("CONCURRENCY_LIMIT"))
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+5 -2
View File
@@ -3,7 +3,10 @@
Encapsula la lógica de estimación previa al procesamiento.
"""
import os
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from application.service import InvoiceProcessingService
from application.profiles import get_profile
from application.prompts import PromptBuilder
@@ -13,7 +16,7 @@ from application.file_discovery import get_sorted_radicados, find_file_for_type_
from core.utils import collect_radicado_docs, collect_documents_flat
class Estimator:
def __init__(self, service):
def __init__(self, service: 'InvoiceProcessingService'):
self.service = service
def estimate(
+9 -4
View File
@@ -32,11 +32,16 @@ logger = logging.getLogger(__name__)
class InvoiceProcessingService:
"""Fachada de negocio para extraer y transformar datos de facturas."""
def __init__(self, extractor: InvoiceExtractorStrategy) -> None:
def __init__(
self,
extractor: InvoiceExtractorStrategy,
batch_processor_factory: Callable[[InvoiceProcessingService], BatchProcessor],
estimator_factory: Callable[[InvoiceProcessingService], Estimator]
) -> None:
self.extractor = extractor
# Componentes delegados
self.batch_processor = BatchProcessor(self)
self.estimator = Estimator(self)
# Componentes delegados inyectados mediante factories para evitar dependencia circular directa en construcción
self.batch_processor = batch_processor_factory(self)
self.estimator = estimator_factory(self)
# Lock para escrituras concurrentes al mismo CSV desde múltiples hilos
# (Mantenido por retrocompatibilidad si se accede directo, aunque BatchProcessor usa su propio CSVManager)
+4 -6
View File
@@ -1,7 +1,5 @@
"""Paquete `gui`: componentes de la interfaz gráfica basada en customtkinter.
"""Módulo de GUI."""
from gui.view import InvoiceAppView
from gui.styles import AppStyles
Incluye:
- `view`: ventana principal y widgets.
- `controller`: controlador de flujos de facturas.
- `field_selector`: diálogo para seleccionar campos y alias.
"""
__all__ = ["InvoiceAppView", "AppStyles"]
+58
View File
@@ -0,0 +1,58 @@
"""Configuración centralizada de estilos, temas y fuentes para la GUI."""
import customtkinter as ctk
class AppStyles:
"""Clase con configuración de estilos y fuentes de la aplicación."""
@staticmethod
def configure_theme():
"""Configura el tema y apariencia general de la aplicación."""
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
@staticmethod
def get_fonts():
"""Devuelve un diccionario con las fuentes personalizadas."""
return {
"title": ctk.CTkFont(family="Roboto", size=22, weight="bold"),
"section": ctk.CTkFont(family="Roboto", size=16, weight="bold"),
"ui": ctk.CTkFont(family="Roboto", size=13),
"mono": ctk.CTkFont(family="Consolas", size=12),
"mono_small": ctk.CTkFont(family="Consolas", size=10),
"italic_small": ctk.CTkFont(size=12, slant="italic"),
"bold_large": ctk.CTkFont(size=16, weight="bold"),
"button_large": ctk.CTkFont(size=14, weight="bold"),
}
@staticmethod
def get_colors():
"""Devuelve un diccionario con los colores personalizados."""
return {
"primary_button": ("#106BA3", "#106BA3"),
"primary_hover": ("#0D5582", "#0D5582"),
"secondary_button": ("#2E86C1", "#2E86C1"),
"secondary_hover": ("#21618C", "#21618C"),
"success": ("#27AE60", "#27AE60"),
"success_hover": ("#1E8449", "#1E8449"),
"warning": ("#D35400", "#D35400"),
"warning_hover": ("#A04000", "#A04000"),
"danger": ("#C0392B", "#C0392B"),
"danger_hover": ("#A93226", "#A93226"),
"gray_light": "gray60",
"gray_dark": "gray70",
"text_muted": ("gray50", "gray70"),
}
@staticmethod
def get_spacing():
"""Devuelve un diccionario con espaciados y padding estándar."""
return {
"padding_small": 2,
"padding_medium": 5,
"padding_large": 10,
"padding_section": 15,
"margin_small": (0, 2),
"margin_medium": (5, 5),
"margin_large": (10, 15),
}
+8
View File
@@ -0,0 +1,8 @@
"""Módulo de pestañas de la GUI."""
from gui.tabs.settings_tab import SettingsTab
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
__all__ = ["SettingsTab", "ResultsTab", "MainProcessingTab", "PreclassificationTab", "MetricsTab"]
+438
View File
@@ -0,0 +1,438 @@
"""Pestaña Procesar Documentos de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
class MainProcessingTab:
"""Encapsula la UI de la pestaña Procesar Documentos."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Procesar Documentos.
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
self._doc_type_rows = []
self._setup_layout()
self._create_paths_section()
self._create_config_section()
self._create_process_button()
self._create_output_section()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.scroll_container = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.scroll_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.scroll_container.grid_columnconfigure(0, weight=1)
def _create_paths_section(self):
"""Crea la sección de rutas y archivos."""
self.frame_paths = ctk.CTkFrame(self.scroll_container)
self.frame_paths.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 2))
self.frame_paths.grid_columnconfigure(1, weight=1)
# Título
lbl_paths = ctk.CTkLabel(
self.frame_paths,
text="Rutas y Archivos",
font=self.fonts["section"]
)
lbl_paths.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(5, 2))
# Carpeta Raíz
self.root_label = ctk.CTkLabel(self.frame_paths, text="Carpeta Raíz:", font=self.fonts["ui"])
self.root_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.root_entry = ctk.CTkEntry(
self.frame_paths,
placeholder_text="Selecciona la carpeta con los documentos...",
font=self.fonts["ui"]
)
self.root_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
self.root_button = ctk.CTkButton(self.frame_paths, text="Explorar...", font=self.fonts["ui"], width=100)
self.root_button.grid(row=1, column=2, padx=15, pady=2)
# CSV Salida
self.csv_label = ctk.CTkLabel(self.frame_paths, text="Archivo CSV:", font=self.fonts["ui"])
self.csv_label.grid(row=2, column=0, padx=15, pady=2, sticky="w")
self.csv_entry = ctk.CTkEntry(
self.frame_paths,
placeholder_text="Ruta para guardar el reporte CSV...",
font=self.fonts["ui"]
)
self.csv_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
self.csv_button = ctk.CTkButton(
self.frame_paths,
text="Guardar en...",
font=self.fonts["ui"],
width=100,
fg_color="transparent",
border_width=1
)
self.csv_button.grid(row=2, column=2, padx=15, pady=2)
# Selector de Modo (Radicados vs General)
self.mode_var = ctk.StringVar(value="radicados")
lbl_mode = ctk.CTkLabel(self.frame_paths, text="Estructura:", font=self.fonts["ui"])
lbl_mode.grid(row=3, column=0, padx=15, pady=2, sticky="w")
self.frame_radios_main = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
self.frame_radios_main.grid(row=3, column=1, columnspan=2, sticky="w", padx=5, pady=2)
self.radio_radicados_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Por Radicados (Subcarpetas)",
variable=self.mode_var,
value="radicados",
font=self.fonts["ui"]
)
self.radio_radicados_main.pack(side="left", padx=(0, 20))
self.radio_general_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Carpeta General (Archivos Planos)",
variable=self.mode_var,
value="general",
font=self.fonts["ui"]
)
self.radio_general_main.pack(side="left")
def _create_config_section(self):
"""Crea la sección de configuración de tipos de documento y TIFF."""
self.frame_config = ctk.CTkFrame(self.scroll_container)
self.frame_config.grid(row=1, column=0, sticky="ew", padx=10, pady=2)
self.frame_config.grid_columnconfigure(0, weight=1)
# Keywords oculto (legacy)
self.kw_label = ctk.CTkLabel(self.frame_config, text="Keywords:")
self.kw_entry = ctk.CTkEntry(self.frame_config)
self.kw_entry.insert(0, "Factura_O_Documento_Equivalente,factura")
# Tipos de Documento
self.doc_types_container = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
self.doc_types_container.grid_columnconfigure(0, weight=1)
self.doc_types_header = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_header.grid(row=0, column=0, sticky="ew", pady=(0, 2))
self.doc_types_header.grid_columnconfigure(0, weight=1)
self.doc_types_label = ctk.CTkLabel(
self.doc_types_header,
text="Definición de Tipos de Documento",
font=self.fonts["section"]
)
self.doc_types_label.grid(row=0, column=0, sticky="w")
self.add_doc_type_btn = ctk.CTkButton(
self.doc_types_header,
text="+ Añadir Tipo",
font=self.fonts["ui"],
width=120,
height=28
)
self.add_doc_type_btn.grid(row=0, column=1, sticky="e")
self.add_doc_type_btn.configure(command=self._add_doc_type_row)
# Lista de Tipos
self.doc_types_list = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_list.grid(row=1, column=0, sticky="ew", pady=(2, 0))
self.doc_types_list.grid_columnconfigure(0, weight=1)
# Fila por defecto
self._add_doc_type_row(name="Factura", keyword="Factura_O_Documento_Equivalente")
# Configuración Avanzada (TIFF Range)
self.frame_advanced = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.frame_advanced.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 2))
self.tiff_start_label = ctk.CTkLabel(
self.frame_advanced,
text="Pág. Inicio TIFF:",
font=self.fonts["ui"]
)
self.tiff_start_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_start_entry.insert(0, "0")
self.tiff_end_label = ctk.CTkLabel(
self.frame_advanced,
text="Pág. Fin TIFF:",
font=self.fonts["ui"]
)
self.tiff_end_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_end_entry.insert(0, "0")
# Ocultar inicialmente
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
# Campos legacy ocultos
self.fields_entry = ctk.CTkEntry(self.tab)
self.fields_label = ctk.CTkLabel(self.tab, text="")
def _create_process_button(self):
"""Crea el botón de acción principal."""
self.process_button = ctk.CTkButton(
self.scroll_container,
text="INICIAR PROCESAMIENTO",
font=self.fonts["bold_large"],
height=35,
fg_color=self.colors["primary_button"],
hover_color=self.colors["primary_hover"]
)
self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew")
def _create_output_section(self):
"""Crea la sección de logs/salida."""
self.log_frame = ctk.CTkFrame(self.scroll_container)
self.log_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(0, 15))
self.log_frame.grid_columnconfigure(0, weight=1)
lbl_logs = ctk.CTkLabel(
self.log_frame,
text="Registro de Actividad:",
font=self.fonts["ui"],
text_color=self.colors["text_muted"]
)
lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
self.output_text = ctk.CTkTextbox(
self.log_frame,
font=self.fonts["mono"],
activate_scrollbars=True,
height=150
)
self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None:
"""Añade una fila de tipo de documento."""
row = ctk.CTkFrame(self.doc_types_list, fg_color="transparent")
row.pack(fill="x", pady=2)
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
grid_inner.pack(fill="x", padx=5, pady=2)
grid_inner.grid_columnconfigure(0, weight=1)
grid_inner.grid_columnconfigure(1, weight=1)
name_entry = ctk.CTkEntry(
grid_inner,
placeholder_text="Nombre Lógico",
height=28,
font=self.fonts["ui"]
)
if name:
name_entry.insert(0, name)
name_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
kw_entry = ctk.CTkEntry(
grid_inner,
placeholder_text="Patrón / Keyword",
height=28,
font=self.fonts["ui"]
)
if keyword:
kw_entry.insert(0, keyword)
kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew")
def _remove():
# Buscar y eliminar de la lista interna
for i, r_data in enumerate(self._doc_type_rows):
if r_data["frame"] == row:
self._doc_type_rows.pop(i)
break
row.destroy()
remove_btn = ctk.CTkButton(
grid_inner,
text="×",
width=30,
height=28,
fg_color=self.colors["danger"],
hover_color=self.colors["danger_hover"],
command=_remove
)
remove_btn.grid(row=0, column=2, sticky="e")
self._doc_type_rows.append({
"frame": row,
"name_entry": name_entry,
"kw_entry": kw_entry,
"remove_btn": remove_btn
})
# --- API Pública ---
def set_on_select_root(self, callback):
"""Vincula callback al botón de selección de carpeta raíz."""
self.root_button.configure(command=callback)
def set_on_select_csv(self, callback):
"""Vincula callback al botón de selección de CSV."""
self.csv_button.configure(command=callback)
def set_on_process(self, callback):
"""Vincula callback al botón de procesar."""
self.process_button.configure(command=callback)
def set_on_mode_change(self, callback):
"""Vincula callback al cambio de modo."""
self.mode_var.trace("w", lambda *args: callback())
def get_root_path(self) -> str:
"""Devuelve la ruta raíz seleccionada."""
return self.root_entry.get().strip()
def get_csv_path(self) -> str:
"""Devuelve la ruta del CSV."""
return self.csv_entry.get().strip()
def get_process_mode(self) -> str:
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
try:
v = (self.mode_var.get() or "").strip().lower()
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def get_keywords(self) -> str:
"""Devuelve los keywords."""
return self.kw_entry.get().strip()
def get_fields(self) -> str:
"""Devuelve los fields (legacy)."""
return self.fields_entry.get().strip()
def get_tiff_start_page(self) -> int:
"""Devuelve la página de inicio TIFF."""
try:
value = self.tiff_start_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def get_tiff_end_page(self) -> int:
"""Devuelve la página final TIFF."""
try:
value = self.tiff_end_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def show_tiff_range_controls(self, show: bool) -> None:
"""Muestra u oculta los controles de rango TIFF."""
if show:
self.tiff_start_label.grid(row=0, column=0, padx=5, pady=5)
self.tiff_start_entry.grid(row=0, column=1, padx=5, pady=5)
self.tiff_end_label.grid(row=0, column=2, padx=5, pady=5)
self.tiff_end_entry.grid(row=0, column=3, padx=5, pady=5)
else:
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
def get_doc_types(self) -> list[dict]:
"""Devuelve la lista de tipos de documento."""
out = []
for it in self._doc_type_rows:
try:
name = it["name_entry"].get().strip()
kw = it["kw_entry"].get().strip()
if name and kw:
out.append({"name": name, "keyword": kw})
except:
pass
return out
def set_doc_types(self, doc_types: list[dict]) -> None:
"""Establece los tipos de documento."""
for it in self._doc_type_rows:
it["frame"].destroy()
self._doc_type_rows = []
if not doc_types:
self._add_doc_type_row("Factura", "Factura_O_Documento_Equivalente")
return
for dt in doc_types:
self._add_doc_type_row(str(dt.get("name", "")), str(dt.get("keyword", "")))
def validate_doc_types(self) -> tuple[bool, str | None]:
"""Valida los tipos de documento."""
seen = set()
for dt in self.get_doc_types():
name = dt.get("name")
if name in seen:
return False, f"Nombre duplicado: {name}"
seen.add(name)
if not seen:
return False, "Debe haber al menos un tipo de documento."
return True, None
def update_ui_for_mode(self):
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
mode = self.get_process_mode()
if mode == "general":
# Ocultar Tipos de Documento
self.doc_types_container.grid_forget()
# Mostrar Keywords (opcional, para filtrar)
self.kw_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.kw_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
# Limpiar valor por defecto si existe
curr = self.kw_entry.get()
if "Factura_O_Documento_Equivalente" in curr:
self.kw_entry.delete(0, "end")
else:
# Mostrar Tipos de Documento
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
# Ocultar Keywords
self.kw_label.grid_forget()
self.kw_entry.grid_forget()
def append_output(self, text: str) -> None:
"""Añade texto al área de salida."""
self.output_text.insert("end", text + "\n")
self.output_text.see("end")
def append_output_safe(self, text: str) -> None:
"""Añade texto al área de salida de forma thread-safe."""
# Nota: se espera que sea llamado con self.after(0, ...)
self.append_output(text)
def enable_controls(self, enabled: bool):
"""Habilita o deshabilita los controles."""
state = "normal" if enabled else "disabled"
self.process_button.configure(state=state, text="PROCESANDO..." if not enabled else "INICIAR PROCESAMIENTO")
if not enabled:
self.process_button.configure(fg_color="gray")
else:
self.process_button.configure(fg_color=self.colors["primary_button"])
self.root_button.configure(state=state)
self.root_entry.configure(state=state)
self.csv_button.configure(state=state)
self.csv_entry.configure(state=state)
self.add_doc_type_btn.configure(state=state)
for row in self._doc_type_rows:
row["name_entry"].configure(state=state)
row["kw_entry"].configure(state=state)
row["remove_btn"].configure(state=state)
+266
View File
@@ -0,0 +1,266 @@
"""Pestaña Dashboard & Métricas de la aplicación GUI."""
import customtkinter as ctk
import tkinter.ttk as ttk
from gui.styles import AppStyles
# matplotlib es opcional
try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
HAS_MPL = True
except Exception:
HAS_MPL = False
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]
import numpy as np
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
+472
View File
@@ -0,0 +1,472 @@
"""Pestaña Preclasificación de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
class PreclassificationTab:
"""Encapsula la UI de la pestaña Preclasificación."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Preclasificación.
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
self._preclass_pattern_rows = []
self._setup_layout()
self._create_root_section()
self._create_options_section()
self._create_target_section()
self._create_patterns_section()
self._create_action_section()
self._create_output_section()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.preclass_container = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.preclass_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.preclass_container.grid_columnconfigure(0, weight=1)
self.preclass_container.grid_rowconfigure(3, weight=1)
self.preclass_container.grid_rowconfigure(5, weight=1)
def _create_root_section(self):
"""Crea la sección de carpeta raíz."""
self.preclass_frame_root = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_root.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10))
self.preclass_frame_root.grid_columnconfigure(1, weight=1)
lbl_pre_root_title = ctk.CTkLabel(
self.preclass_frame_root,
text="Carpeta Raíz (Preclasificación)",
font=self.fonts["section"]
)
lbl_pre_root_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5))
self.preclass_root_label = ctk.CTkLabel(
self.preclass_frame_root,
text="Carpeta Raíz:",
font=self.fonts["ui"]
)
self.preclass_root_label.grid(row=1, column=0, padx=15, pady=(0, 10), sticky="w")
self.preclass_root_entry = ctk.CTkEntry(
self.preclass_frame_root,
placeholder_text="Selecciona la carpeta raíz para preclasificar...",
font=self.fonts["ui"]
)
self.preclass_root_entry.grid(row=1, column=1, padx=5, pady=(0, 10), sticky="ew")
self.preclass_root_button = ctk.CTkButton(
self.preclass_frame_root,
text="Explorar...",
font=self.fonts["ui"],
width=100
)
self.preclass_root_button.grid(row=1, column=2, padx=15, pady=(0, 10))
# Selector de Modo
self.preclass_mode_var = ctk.StringVar(value="radicados")
lbl_mode = ctk.CTkLabel(self.preclass_frame_root, text="Estructura:", font=self.fonts["ui"])
lbl_mode.grid(row=2, column=0, padx=15, pady=(0, 10), sticky="w")
self.frame_radios = ctk.CTkFrame(self.preclass_frame_root, fg_color="transparent")
self.frame_radios.grid(row=2, column=1, columnspan=2, sticky="w", padx=5, pady=(0, 10))
self.radio_radicados = ctk.CTkRadioButton(
self.frame_radios,
text="Por Radicados (Subcarpetas)",
variable=self.preclass_mode_var,
value="radicados",
font=self.fonts["ui"]
)
self.radio_radicados.pack(side="left", padx=(0, 20))
self.radio_general = ctk.CTkRadioButton(
self.frame_radios,
text="Carpeta General (Archivos Planos)",
variable=self.preclass_mode_var,
value="general",
font=self.fonts["ui"]
)
self.radio_general.pack(side="left")
def _create_options_section(self):
"""Crea la sección de opciones de rango."""
self.preclass_frame_options = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_options.grid(row=1, column=0, sticky="ew", padx=5, pady=(0, 10))
self.preclass_frame_options.grid_columnconfigure(1, weight=1)
self.preclass_range_var = ctk.BooleanVar(value=False)
self.preclass_range_cb = ctk.CTkCheckBox(
self.preclass_frame_options,
text="Habilitar rangos de página",
variable=self.preclass_range_var,
font=self.fonts["ui"],
command=self._on_toggle_preclass_range
)
self.preclass_range_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
self.preclass_range_inputs_frame = ctk.CTkFrame(self.preclass_frame_options, fg_color="transparent")
lbl_desde = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Desde:", font=self.fonts["ui"])
lbl_desde.grid(row=0, column=0, padx=(10, 5), pady=5)
self.preclass_range_start_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.fonts["ui"])
self.preclass_range_start_entry.insert(0, "1")
self.preclass_range_start_entry.grid(row=0, column=1, padx=5, pady=5)
lbl_hasta = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Hasta:", font=self.fonts["ui"])
lbl_hasta.grid(row=0, column=2, padx=(10, 5), pady=5)
self.preclass_range_end_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.fonts["ui"])
self.preclass_range_end_entry.insert(0, "1")
self.preclass_range_end_entry.grid(row=0, column=3, padx=5, pady=5)
def _create_target_section(self):
"""Crea la sección de archivo objetivo."""
self.preclass_frame_target = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_target.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 10))
self.preclass_frame_target.grid_columnconfigure(0, weight=1)
self.preclass_target_var = ctk.BooleanVar(value=False)
self.preclass_target_cb = ctk.CTkCheckBox(
self.preclass_frame_target,
text="Habilitar archivo objetivo",
variable=self.preclass_target_var,
font=self.fonts["ui"],
command=self._on_toggle_preclass_target
)
self.preclass_target_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
self.preclass_target_inputs_frame = ctk.CTkFrame(self.preclass_frame_target, fg_color="transparent")
lbl_target_def = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Def. Tipo Doc:", font=self.fonts["ui"])
lbl_target_def.grid(row=0, column=0, padx=(15, 5), pady=5, sticky="w")
self.preclass_target_def_entry = ctk.CTkEntry(
self.preclass_target_inputs_frame,
width=250,
font=self.fonts["ui"],
placeholder_text="Ej: Factura de Venta"
)
self.preclass_target_def_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w")
lbl_target_pattern = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Patrón:", font=self.fonts["ui"])
lbl_target_pattern.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w")
self.preclass_target_pattern_entry = ctk.CTkEntry(
self.preclass_target_inputs_frame,
width=200,
font=self.fonts["ui"],
placeholder_text="Ej: *_FAC_*.pdf"
)
self.preclass_target_pattern_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
def _create_patterns_section(self):
"""Crea la sección de patrones."""
self.preclass_frame_patterns = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_patterns.grid(row=3, column=0, sticky="nsew", padx=5, pady=5)
self.preclass_frame_patterns.grid_columnconfigure(0, weight=1)
self.preclass_patterns_header = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
self.preclass_patterns_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 2))
self.preclass_patterns_header.grid_columnconfigure(0, weight=1)
self.preclass_patterns_label = ctk.CTkLabel(
self.preclass_patterns_header,
text="Patrones",
font=self.fonts["section"]
)
self.preclass_patterns_label.grid(row=0, column=0, sticky="w")
self.add_preclass_pattern_btn = ctk.CTkButton(
self.preclass_patterns_header,
text="+ Añadir Patrón",
font=self.fonts["ui"],
width=140,
height=28
)
self.add_preclass_pattern_btn.grid(row=0, column=1, sticky="e")
self.add_preclass_pattern_btn.configure(command=self._add_preclass_pattern_row)
self.preclass_patterns_hint = ctk.CTkLabel(
self.preclass_frame_patterns,
text="Agrega patrones de texto. Cada patrón puede ser solo 'key' o 'key + value'.",
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
justify="left"
)
self.preclass_patterns_hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5))
self.preclass_patterns_list = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
self.preclass_patterns_list.grid(row=2, column=0, sticky="ew", padx=10, pady=(2, 10))
self.preclass_patterns_list.grid_columnconfigure(0, weight=1)
# Fila por defecto (vacía)
self._add_preclass_pattern_row()
def _create_action_section(self):
"""Crea la sección de acciones."""
self.preclass_action_frame = ctk.CTkFrame(self.preclass_container, fg_color="transparent")
self.preclass_action_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=10)
self.preclass_action_frame.grid_columnconfigure(0, weight=1)
self.preclass_run_btn = ctk.CTkButton(
self.preclass_action_frame,
text="EJECUTAR PRECLASIFICACIÓN",
font=self.fonts["bold_large"],
height=40,
fg_color=self.colors["secondary_button"],
hover_color=self.colors["secondary_hover"]
)
self.preclass_run_btn.pack(side="left", fill="x", expand=True, padx=10)
self.preclass_export_btn = ctk.CTkButton(
self.preclass_action_frame,
text="Exportar Excel",
font=self.fonts["ui"],
height=40,
state="disabled",
fg_color=self.colors["success"],
hover_color=self.colors["success_hover"]
)
self.preclass_export_btn.pack(side="left", padx=(0, 10))
self.preclass_organize_btn = ctk.CTkButton(
self.preclass_action_frame,
text="Organizar Carpetas",
font=self.fonts["ui"],
height=40,
state="disabled",
fg_color=self.colors["warning"],
hover_color=self.colors["warning_hover"]
)
self.preclass_organize_btn.pack(side="left", padx=(0, 10))
def _create_output_section(self):
"""Crea la sección de salida/logs."""
self.preclass_log_frame = ctk.CTkFrame(self.preclass_container)
self.preclass_log_frame.grid(row=5, column=0, sticky="nsew", padx=5, pady=(0, 10))
self.preclass_log_frame.grid_columnconfigure(0, weight=1)
self.preclass_log_frame.grid_rowconfigure(1, weight=1)
lbl_pre_logs = ctk.CTkLabel(
self.preclass_log_frame,
text="Resultados:",
font=self.fonts["ui"],
text_color=self.colors["text_muted"]
)
lbl_pre_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
self.preclass_output = ctk.CTkTextbox(
self.preclass_log_frame,
font=self.fonts["mono"],
activate_scrollbars=True
)
self.preclass_output.grid(row=1, column=0, padx=5, pady=5, sticky="nsew")
def _add_preclass_pattern_row(self, key: str = "", mode: str = "key", value: str = "") -> None:
"""Añade una fila editable para un patrón de preclasificación."""
row = ctk.CTkFrame(self.preclass_patterns_list, fg_color="transparent")
row.pack(fill="x", pady=2)
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
grid_inner.pack(fill="x", padx=5, pady=2)
grid_inner.grid_columnconfigure(0, weight=1)
grid_inner.grid_columnconfigure(2, weight=1)
key_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Key", height=28, font=self.fonts["ui"])
if key:
key_entry.insert(0, key)
key_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
mode_combo = ctk.CTkComboBox(
grid_inner,
values=["key", "key+value"],
width=110,
font=self.fonts["ui"]
)
mode_combo.grid(row=0, column=1, padx=(0, 5), sticky="w")
value_entry = ctk.CTkEntry(
grid_inner,
placeholder_text="Value (si aplica)",
height=28,
font=self.fonts["ui"]
)
if value:
value_entry.insert(0, value)
value_entry.grid(row=0, column=2, padx=(0, 5), sticky="ew")
def _apply_mode(m: str):
m_norm = (m or "").strip().lower()
if m_norm in ("key+value", "key + value", "key_value", "key-value"):
value_entry.configure(state="normal", placeholder_text="Value (requerido)")
else:
value_entry.delete(0, "end")
value_entry.configure(state="disabled", placeholder_text="Value (no aplica)")
def _on_mode_change(choice: str):
_apply_mode(choice)
try:
mode_combo.set(mode if mode in ("key", "key+value") else "key")
except Exception:
mode_combo.set("key")
mode_combo.configure(command=_on_mode_change)
_apply_mode(mode_combo.get())
def _remove():
try:
for i, r_data in enumerate(list(self._preclass_pattern_rows)):
if r_data.get("frame") == row:
self._preclass_pattern_rows.pop(i)
break
except Exception:
pass
row.destroy()
remove_btn = ctk.CTkButton(
grid_inner,
text="×",
width=30,
height=28,
fg_color=self.colors["danger"],
hover_color=self.colors["danger_hover"],
command=_remove
)
remove_btn.grid(row=0, column=3, sticky="e")
self._preclass_pattern_rows.append({
"frame": row,
"key_entry": key_entry,
"mode_combo": mode_combo,
"value_entry": value_entry,
"remove_btn": remove_btn,
})
def _on_toggle_preclass_range(self):
"""Muestra u oculta los inputs de rango según el checkbox."""
if self.preclass_range_var.get():
self.preclass_range_inputs_frame.grid(row=0, column=1, padx=10, pady=0, sticky="w")
else:
self.preclass_range_inputs_frame.grid_forget()
def _on_toggle_preclass_target(self):
"""Muestra u oculta los inputs de archivo objetivo según el checkbox."""
if self.preclass_target_var.get():
self.preclass_target_inputs_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="w")
else:
self.preclass_target_inputs_frame.grid_forget()
# --- API Pública ---
def set_on_run(self, callback):
"""Vincula callback al botón de ejecutar."""
self.preclass_run_btn.configure(command=callback)
def set_on_export_excel(self, callback):
"""Vincula callback al botón de exportar Excel."""
self.preclass_export_btn.configure(command=callback)
def set_on_organize_files(self, callback):
"""Vincula callback al botón de organizar carpetas."""
self.preclass_organize_btn.configure(command=callback)
def set_on_select_root(self, callback):
"""Vincula callback al botón de seleccionar carpeta."""
self.preclass_root_button.configure(command=callback)
def get_root_path(self) -> str:
"""Devuelve la ruta raíz de preclasificación."""
return self.preclass_root_entry.get().strip()
def get_mode(self) -> str:
"""Devuelve el modo de preclasificación: 'radicados' o 'general'."""
try:
v = (self.preclass_mode_var.get() or "").strip().lower()
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def get_range(self) -> tuple[bool, int, int]:
"""Devuelve (habilitado, desde, hasta) para preclasificación."""
enabled = self.preclass_range_var.get()
try:
start = int(self.preclass_range_start_entry.get().strip() or "1")
end = int(self.preclass_range_end_entry.get().strip() or "1")
except Exception:
start, end = 1, 1
return enabled, start, end
def get_target_info(self) -> dict:
"""Devuelve la configuración del archivo objetivo."""
return {
"enabled": self.preclass_target_var.get(),
"definition": self.preclass_target_def_entry.get().strip(),
"pattern": self.preclass_target_pattern_entry.get().strip()
}
def get_patterns(self) -> list[dict]:
"""Devuelve la lista de patrones definida."""
out = []
for it in getattr(self, "_preclass_pattern_rows", []):
try:
key = it["key_entry"].get().strip()
mode = (it["mode_combo"].get() or "").strip()
value = it["value_entry"].get().strip()
if not key:
continue
if mode == "key+value":
out.append({"key": key, "mode": "key+value", "value": value})
else:
out.append({"key": key, "mode": "key"})
except Exception:
pass
return out
def append_output(self, text: str) -> None:
"""Añade texto al área de salida."""
self.preclass_output.insert("end", text + "\n")
self.preclass_output.see("end")
def append_output_safe(self, text: str) -> None:
"""Añade texto al área de salida de forma thread-safe."""
self.append_output(text)
def enable_actions(self, enabled: bool):
"""Habilita o deshabilita los botones de acción."""
state = "normal" if enabled else "disabled"
self.preclass_export_btn.configure(state=state)
self.preclass_organize_btn.configure(state=state)
def enable_controls(self, enabled: bool):
"""Habilita o deshabilita los controles principales."""
state = "normal" if enabled else "disabled"
self.preclass_root_button.configure(state=state)
self.preclass_root_entry.configure(state=state)
self.preclass_range_cb.configure(state=state)
self.preclass_range_start_entry.configure(state=state)
self.preclass_range_end_entry.configure(state=state)
self.preclass_target_cb.configure(state=state)
self.preclass_target_def_entry.configure(state=state)
self.preclass_target_pattern_entry.configure(state=state)
self.radio_radicados.configure(state=state)
self.radio_general.configure(state=state)
self.add_preclass_pattern_btn.configure(state=state)
self.preclass_run_btn.configure(state=state)
for row in self._preclass_pattern_rows:
row["key_entry"].configure(state=state)
row["mode_combo"].configure(state=state)
row["value_entry"].configure(state=state)
row["remove_btn"].configure(state=state)
+251
View File
@@ -0,0 +1,251 @@
"""Pestaña de Resultados de la aplicación GUI."""
import os
import customtkinter as ctk
import tkinter.ttk as ttk
from gui.styles import AppStyles
class ResultsTab:
"""Encapsula la UI de la pestaña Resultados."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Resultados.
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()
self._setup_layout()
self._create_subtabs()
self._setup_treeview_style()
self._create_extractor_tab()
self._create_preclass_results_tab()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.tabs_res = ctk.CTkTabview(self.tab)
self.tabs_res.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
def _create_subtabs(self):
"""Crea las sub-pestañas de Resultados."""
self.tab_res_extractor = self.tabs_res.add("Extractor (Próximamente)")
self.tab_res_preclass = self.tabs_res.add("Preclasificación")
def _setup_treeview_style(self):
"""Configura el estilo de los treeviews."""
style = ttk.Style()
style.theme_use("clam")
style.configure("Treeview", rowheight=25)
def _create_extractor_tab(self):
"""Crea la sub-pestaña del Extractor (en construcción)."""
lbl_const = ctk.CTkLabel(
self.tab_res_extractor,
text="En construcción...",
font=ctk.CTkFont(size=20)
)
lbl_const.place(relx=0.5, rely=0.5, anchor="center")
def _create_preclass_results_tab(self):
"""Crea la sub-pestaña de resultados de preclasificación."""
self.tab_res_preclass.grid_columnconfigure(0, weight=1)
self.tab_res_preclass.grid_rowconfigure(1, weight=1)
self._create_filters_section()
self._create_tables_section()
self._create_actions_section()
def _create_filters_section(self):
"""Crea la sección de filtros."""
self.frame_res_filters = ctk.CTkFrame(self.tab_res_preclass)
self.frame_res_filters.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
ctk.CTkLabel(self.frame_res_filters, text="Run ID:").grid(row=0, column=0, padx=5, pady=5)
self.combo_res_run = ctk.CTkComboBox(self.frame_res_filters, values=["Todos"], width=150)
self.combo_res_run.grid(row=0, column=1, padx=5, pady=5)
ctk.CTkLabel(self.frame_res_filters, text="Estado:").grid(row=0, column=2, padx=5, pady=5)
self.combo_res_status = ctk.CTkComboBox(
self.frame_res_filters,
values=["Todos", "MATCH", "NO_MATCH", "ERROR", "NO_FILES"],
width=120
)
self.combo_res_status.grid(row=0, column=3, padx=5, pady=5)
self.entry_res_search = ctk.CTkEntry(
self.frame_res_filters,
placeholder_text="Buscar radicado/archivo...",
width=200
)
self.entry_res_search.grid(row=0, column=4, padx=5, pady=5)
self.btn_res_search = ctk.CTkButton(self.frame_res_filters, text="Buscar", width=100)
self.btn_res_search.grid(row=0, column=5, padx=10, pady=5)
def _create_tables_section(self):
"""Crea la sección con las tablas (principal y de detalle)."""
self.frame_res_table = ctk.CTkFrame(self.tab_res_preclass, fg_color="transparent")
self.frame_res_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=5)
self.frame_res_table.grid_columnconfigure(0, weight=1)
self.frame_res_table.grid_rowconfigure(0, weight=3)
self.frame_res_table.grid_rowconfigure(2, weight=2)
self._create_main_results_table()
self._create_detail_label()
self._create_matches_table()
def _create_main_results_table(self):
"""Crea la tabla principal de resultados."""
cols = ("ID", "Run", "Fecha", "Radicado", "Estado", "Archivo")
self.tree_results = ttk.Treeview(
self.frame_res_table,
columns=cols,
show="headings",
selectmode="browse"
)
for c in cols:
self.tree_results.heading(c, text=c)
self.tree_results.column(c, width=100)
self.tree_results.column("Archivo", width=300)
self.tree_results.column("ID", width=50)
self.tree_results.column("Run", width=50)
# Scrollbars
sb_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_results.yview)
sb_x = ttk.Scrollbar(self.frame_res_table, orient="horizontal", command=self.tree_results.xview)
self.tree_results.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set)
self.tree_results.grid(row=0, column=0, sticky="nsew")
sb_y.grid(row=0, column=1, sticky="ns")
sb_x.grid(row=1, column=0, sticky="ew")
def _create_detail_label(self):
"""Crea la etiqueta de la sección de detalle."""
lbl_detail = ctk.CTkLabel(
self.frame_res_table,
text="Detalle de Matches (Seleccionar fila arriba)",
font=ctk.CTkFont(size=12, weight="bold")
)
lbl_detail.grid(row=1, column=0, sticky="w", pady=(10, 5))
def _create_matches_table(self):
"""Crea la tabla de detalle de matches."""
cols_detail = ("Key", "Valor Encontrado", "Existe", "Valor Esperado")
self.tree_matches = ttk.Treeview(
self.frame_res_table,
columns=cols_detail,
show="headings",
selectmode="browse",
height=5
)
for c in cols_detail:
self.tree_matches.heading(c, text=c)
self.tree_matches.column(c, width=150)
sb_detail_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_matches.yview)
self.tree_matches.configure(yscrollcommand=sb_detail_y.set)
self.tree_matches.grid(row=2, column=0, sticky="nsew")
sb_detail_y.grid(row=2, column=1, sticky="ns")
def _create_actions_section(self):
"""Crea la sección de acciones."""
self.frame_res_actions = ctk.CTkFrame(self.tab_res_preclass)
self.frame_res_actions.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
self.btn_res_export = ctk.CTkButton(
self.frame_res_actions,
text="Exportar Vista a Excel",
fg_color=self.colors["success"]
)
self.btn_res_export.pack(side="right", padx=10, pady=5)
self.btn_res_organize = ctk.CTkButton(
self.frame_res_actions,
text="Organizar Carpetas (Run)",
fg_color=self.colors["warning"],
hover_color=self.colors["warning_hover"]
)
self.btn_res_organize.pack(side="right", padx=10, pady=5)
self.btn_res_refresh = ctk.CTkButton(self.frame_res_actions, text="Refrescar Runs")
self.btn_res_refresh.pack(side="left", padx=10, pady=5)
# --- API Pública ---
def set_on_search(self, callback):
"""Vincula el callback al botón de búsqueda."""
self.btn_res_search.configure(command=callback)
def set_on_export(self, callback):
"""Vincula el callback al botón de exportación."""
self.btn_res_export.configure(command=callback)
def set_on_refresh(self, callback):
"""Vincula el callback al botón de refrescar."""
self.btn_res_refresh.configure(command=callback)
def set_on_organize(self, callback):
"""Vincula el callback al botón de organizar."""
self.btn_res_organize.configure(command=callback)
def set_on_select_result(self, callback):
"""Vincula el callback al evento de selección de fila."""
self.tree_results.bind("<<TreeviewSelect>>", callback)
def update_matches_table(self, rows):
"""Actualiza la tabla de matches con los datos proporcionados."""
for item in self.tree_matches.get_children():
self.tree_matches.delete(item)
for r in rows:
exists_str = "✅" if r.get("exists_bool") else "❌"
vals = (r.get("key"), r.get("found_value"), exists_str, r.get("expected_value") or "-")
self.tree_matches.insert("", "end", values=vals)
def update_runs_combo(self, runs: list):
"""Actualiza el combo box con los IDs de ejecuciones disponibles."""
vals = ["Todos"] + [str(r) for r in runs]
self.combo_res_run.configure(values=vals)
self.combo_res_run.set("Todos")
def set_run_id(self, run_id: int | str):
"""Establece el ID de ejecución seleccionado."""
try:
self.combo_res_run.set(str(run_id))
except Exception:
pass
def get_filters(self) -> dict:
"""Devuelve los filtros actuales."""
return {
"run_id": self.combo_res_run.get(),
"status": self.combo_res_status.get(),
"text": self.entry_res_search.get()
}
def update_results_table(self, rows):
"""Actualiza la tabla de resultados con los datos proporcionados."""
for item in self.tree_results.get_children():
self.tree_results.delete(item)
for r in rows:
vals = (
r.get("id"),
r.get("run_id"),
r.get("timestamp"),
r.get("radicado"),
r.get("status"),
os.path.basename(r.get("file_path") or "")
)
self.tree_results.insert("", "end", values=vals)
+163
View File
@@ -0,0 +1,163 @@
"""Pestaña de Configuración de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
class SettingsTab:
"""Encapsula la UI de la pestaña Configuración."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Configuración.
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()
self._setup_layout()
self._create_api_section()
self._create_processing_section()
self._create_save_button()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.settings_container = ctk.CTkFrame(self.tab, fg_color="transparent")
self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
self.settings_container.grid_columnconfigure(0, weight=1)
def _create_api_section(self):
"""Crea la sección de configuración del modelo LLM."""
self.frame_api = ctk.CTkFrame(self.settings_container)
self.frame_api.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
self.frame_api.grid_columnconfigure(1, weight=1)
# Título
lbl_api_title = ctk.CTkLabel(
self.frame_api,
text="Configuración del Modelo LLM",
font=self.fonts["section"]
)
lbl_api_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
# Selector de modelo
lbl_model = ctk.CTkLabel(
self.frame_api,
text="Modelo (Ollama):",
font=self.fonts["ui"]
)
lbl_model.grid(row=1, column=0, padx=15, pady=10, sticky="w")
self.combo_model = ctk.CTkComboBox(
self.frame_api,
values=["5.1", "4.1", "4.1 mini", "4.1 nano"],
width=250,
font=self.fonts["ui"]
)
self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w")
self.combo_model.set("4.1")
# Texto de ayuda
help_text = (
"ℹ 4.1 mini: Muy rápido (recomendado)\n"
"ℹ 4.1: Estándar (balanceado)\n"
"ℹ 5.1: Lento pero mayor razonamiento"
)
lbl_help = ctk.CTkLabel(
self.frame_api,
text=help_text,
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
justify="left"
)
lbl_help.grid(row=2, column=1, padx=15, pady=(0, 10), sticky="w")
def _create_processing_section(self):
"""Crea la sección de procesamiento y rendimiento."""
self.frame_proc = ctk.CTkFrame(self.settings_container)
self.frame_proc.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
self.frame_proc.grid_columnconfigure(1, weight=1)
# Título
lbl_proc_title = ctk.CTkLabel(
self.frame_proc,
text="Procesamiento y Rendimiento",
font=self.fonts["section"]
)
lbl_proc_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
# Límite de concurrencia
lbl_conc = ctk.CTkLabel(
self.frame_proc,
text="Límite de Concurrencia:",
font=self.fonts["ui"]
)
lbl_conc.grid(row=1, column=0, padx=15, pady=10, sticky="w")
self.concurrency_var = ctk.StringVar(value="100")
self.concurrency_entry = ctk.CTkEntry(
self.frame_proc,
textvariable=self.concurrency_var,
width=100,
font=self.fonts["ui"]
)
self.concurrency_entry.grid(row=1, column=1, padx=15, pady=10, sticky="w")
# Advertencia
warning_text = (
"⚠️ Advertencia: Entre mayor el número, mayor la probabilidad de Rate Limits (429)."
)
lbl_conc_warn = ctk.CTkLabel(
self.frame_proc,
text=warning_text,
font=self.fonts["italic_small"],
text_color="orange"
)
lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w")
def _create_save_button(self):
"""Crea el botón de guardar configuración."""
self.btn_save_settings = ctk.CTkButton(
self.settings_container,
text="Aplicar Configuración",
font=self.fonts["ui"],
height=35
)
self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew")
# --- API Pública ---
def set_on_save(self, callback):
"""Vincula el callback al botón de guardar."""
self.btn_save_settings.configure(command=callback)
def get_selected_model(self) -> str:
"""Devuelve el modelo seleccionado."""
return self.combo_model.get()
def set_selected_model(self, model: str) -> None:
"""Establece el modelo seleccionado."""
self.combo_model.set(model)
def get_concurrency_limit(self) -> int:
"""Devuelve el límite de concurrencia."""
try:
return int(self.concurrency_var.get())
except Exception:
return 20
def set_concurrency_limit(self, limit: int) -> None:
"""Establece el límite de concurrencia."""
self.concurrency_var.set(str(limit))
def enable_controls(self, enabled: bool):
"""Habilita o deshabilita los controles."""
state = "normal" if enabled else "disabled"
self.combo_model.configure(state=state)
self.concurrency_entry.configure(state=state)
self.btn_save_settings.configure(state=state)
+88 -1055
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -38,7 +38,15 @@ def main():
base_url=settings.api.base_url,
model=settings.api.model
)
service = InvoiceProcessingService(extractor)
from application.batch_processor import BatchProcessor
from application.estimator import Estimator
service = InvoiceProcessingService(
extractor,
batch_processor_factory=lambda s: BatchProcessor(s),
estimator_factory=lambda s: Estimator(s)
)
app = InvoiceAppView()
AppController(app, service)